diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index a2b66522e8..bffb6281df 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -4134,7 +4134,7 @@ component extends="modules.BaseModule" { pattern: "", checkType: "directory", path: "plugins", - fix: "Migrate to packages/ + vendor/ activation model" + fix: "Migrate plugins to packages installed under vendor/ (wheels packages add )" }); } arrayAppend(checks, { @@ -4150,11 +4150,11 @@ component extends="modules.BaseModule" { // 3.x -> 4.x if (currentMajor <= 3 && targetMajor >= 4) { arrayAppend(checks, { - description: "Legacy plugin directory (deprecated in 4.x)", + description: "Legacy plugin directory (deprecated as of 4.0, removed in 5.0)", pattern: "", checkType: "directory", path: "plugins", - fix: "Migrate to packages/ + vendor/ system" + fix: "Migrate plugins to packages installed under vendor/ (wheels packages add )" }); // Matches both quote styles and the silent wheels.Testbox shim // (deprecated alias of wheels.WheelsTest, removal target 5.0) — diff --git a/vendor/wheels/Global.cfc b/vendor/wheels/Global.cfc index 4cf06a54ee..8e6b3e513d 100644 --- a/vendor/wheels/Global.cfc +++ b/vendor/wheels/Global.cfc @@ -2950,6 +2950,64 @@ return local.$wheels; return local.rv; } + /** + * Internal function. Records a deprecation warning through a single shared + * policy: the first call for a given feature logs a warning to the standard + * wheels log and registers the warning in + * application[appKey].deprecationWarnings so running apps can surface it + * (debug panel, tooling). Subsequent calls for the same feature are no-ops, + * making the helper safe to call from per-request code paths. The dedup + * check, registration, and log write run atomically under an exclusive + * lock so concurrent first callers (e.g. parallel first requests hitting a + * deprecated per-request helper) register and log exactly once. If the + * Wheels application struct does not exist yet, the helper is a silent + * no-op: with no registry to dedup against, logging would fire on every + * call, and all framework callers run after the struct is established. + * + * @feature Stable identifier for the deprecated feature (e.g. "plugins-directory", "paginationLinks"). + * @message Human-readable message: what is deprecated, what replaces it, and when it goes away. + * @docUrl Optional URL of the migration guide, appended to the logged message. + */ + public void function $deprecated(required string feature, required string message, string docUrl = "") { + try { + local.appKey = $appKey(); + if (StructKeyExists(application, local.appKey)) { + // One app-wide lock (rather than per-feature) also serializes the lazy + // creation of the registry array itself; contention is a non-issue at + // once-per-feature-per-application frequency. + lock name="wheels_deprecated_registry" type="exclusive" timeout="5" { + if (!StructKeyExists(application[local.appKey], "deprecationWarnings")) { + application[local.appKey].deprecationWarnings = []; + } + for (local.existing in application[local.appKey].deprecationWarnings) { + if (local.existing.feature == arguments.feature) { + return; + } + } + ArrayAppend(application[local.appKey].deprecationWarnings, { + feature = arguments.feature, + message = arguments.message, + url = arguments.docUrl + }); + // Log if-and-only-if the registration above just succeeded; the + // registry is what enforces the warn-once policy for the log too. + try { + local.text = "[Wheels] Deprecation: " & arguments.message; + if (Len(arguments.docUrl)) { + local.text &= " See: " & arguments.docUrl; + } + WriteLog(type = "warning", text = local.text, file = "wheels"); + } catch (any e) { + // Logging is best-effort; the registry entry above already records the warning. + } + } + } + } catch (any e) { + // Best-effort by design (including lock timeouts); never let a + // deprecation notice break the caller. + } + } + // Returns the running framework version. Delegates to BuildInfo.cfc, which // is the authoritative version source. The historical box.json-reading // implementation (with monorepo / wheels-base-template fallback chain) diff --git a/vendor/wheels/Plugins.cfc b/vendor/wheels/Plugins.cfc index a01d5405e1..4171ff13a3 100644 --- a/vendor/wheels/Plugins.cfc +++ b/vendor/wheels/Plugins.cfc @@ -38,7 +38,7 @@ component output="false" extends="wheels.Global"{ $processMixins(); /* dependencies */ $determineDependency(); - /* deprecation warning: plugins/ directory is deprecated in favor of packages/ */ + /* deprecation warning: plugins/ directory is deprecated in favor of packages installed in vendor/ */ $checkPluginsDeprecation(); return this; } @@ -143,7 +143,7 @@ component output="false" extends="wheels.Global"{ WriteLog( text = "[Wheels] Loading plugin '#local.pluginKey#' from #local.pluginValue.folderPath#", type = "information", - file = "wheels_security" + file = "wheels" ); } catch (any e) {} local.plugin = CreateObject("component", $componentPathToPlugin(local.pluginKey, local.pluginValue.name)).init(); @@ -168,12 +168,19 @@ component output="false" extends="wheels.Global"{ && !$isServiceProvider(local.plugin) && !$hasPluginManifest(local.pluginKey) ) { - local.warning = 'Plugin "#local.pluginKey#" uses legacy mixin injection without a plugin.json manifest or ServiceProvider.cfc. Mixin-only plugins will be deprecated in Wheels 4.0. See: https://guides.wheels.dev/docs/migrating-plugins-to-service-providers'; + local.warning = 'Plugin "#local.pluginKey#" uses legacy mixin injection without a plugin.json manifest or ServiceProvider.cfc. Legacy plugins are deprecated as of Wheels 4.0 and will be removed in Wheels 5.0 — migrate it to a package installed under vendor/.'; + // Intentional dual registration: this per-instance array feeds the public + // getDeprecationWarnings() accessor (existing tooling/test surface), while + // $deprecated() below owns app-wide warn-once logging and the debug panel. ArrayAppend(variables.$class.deprecationWarnings, { plugin = local.pluginKey, message = local.warning }); - WriteLog(type="warning", text="[Wheels] #local.warning#"); + $deprecated( + feature = "plugins:mixin-only:#local.pluginKey#", + message = local.warning, + docUrl = "https://guides.wheels.dev/v4-0-0/upgrading/3x-to-4x/" + ); } if ($isVersionMismatch(local.compatVersion, local.wheelsVersion)) { variables.$class.incompatiblePlugins = ListAppend(variables.$class.incompatiblePlugins, local.pluginKey); @@ -243,7 +250,7 @@ component output="false" extends="wheels.Global"{ // Log an info-level suggestion so authors know about the new manifest option. WriteLog( type = "information", - text = "[Wheels] Plugin '#local.plugin#' does not have a plugin.json manifest. Consider adding one for declarative metadata, dependency management, and middleware registration. See: https://guides.wheels.dev/docs/plugin-json-manifest" + text = "[Wheels] Plugin '#local.plugin#' does not have a plugin.json manifest. Consider adding one for declarative metadata, dependency management, and middleware registration — or migrate the plugin to a package installed under vendor/. See: https://guides.wheels.dev/v4-0-0/digging-deeper/packages/" ); } } @@ -600,15 +607,17 @@ component output="false" extends="wheels.Global"{ } /** - * Logs a deprecation warning if the plugins/ directory contains any loaded plugins. - * The plugins/ directory is deprecated in favor of the packages/ system. + * Records a deprecation warning if the plugins/ directory contains any loaded plugins. + * The plugins/ directory is deprecated in favor of the package system (packages are + * installed directly into vendor/). */ private void function $checkPluginsDeprecation() { if (!StructIsEmpty(variables.$class.plugins)) { local.pluginList = StructKeyList(variables.$class.plugins); - WriteLog( - type = "warning", - text = "[Wheels] The plugins/ directory is deprecated as of Wheels 4.0 and will be removed in Wheels 5.0. Plugins found: #local.pluginList#. Move them to packages/ and activate by copying to vendor/. See: https://wheels.dev/docs/packages" + $deprecated( + feature = "plugins-directory", + message = "The plugins/ directory is deprecated as of Wheels 4.0 and will be removed in Wheels 5.0. Plugins found: #local.pluginList#. Migrate each one to a package installed under vendor/ (`wheels packages add ` for published packages).", + docUrl = "https://guides.wheels.dev/v4-0-0/digging-deeper/packages/" ); } } @@ -709,11 +718,21 @@ component output="false" extends="wheels.Global"{ if (!StructKeyExists(arguments.plugin, "onPluginLoad") || !IsCustomFunction(arguments.plugin.onPluginLoad)) { return; } - local.loadContext = Duplicate(application); + // Shallow copy: the Adobe CF workaround only requires a plain struct + // context (the application scope itself rejects function members), not + // a deep clone. Shared keys keep referencing the live objects (DI + // container, config struct, framework instance) so nothing forks, and + // the per-plugin cost is O(top-level keys) instead of a deep copy of + // the entire application scope. + local.loadContext = StructCopy(application); $installPluginLoadAPI(arguments.pluginKey, local.loadContext); arguments.plugin.onPluginLoad(local.loadContext); - // Sync non-function keys back to application scope. Closures injected - // by $installPluginLoadAPI are skipped to keep application clean. + // Sync non-function keys back to the application scope. Closures + // injected by $installPluginLoadAPI are skipped to keep application + // clean. For shared keys this re-assigns the same reference (a no-op); + // the loop matters for keys the plugin added or replaced, and for + // arrays on Adobe CF, which copies arrays by value even in a shallow + // StructCopy. for (local.contextKey in local.loadContext) { if (!IsCustomFunction(local.loadContext[local.contextKey])) { application[local.contextKey] = local.loadContext[local.contextKey]; @@ -847,13 +866,17 @@ component output="false" extends="wheels.Global"{ if (IsDefined("application") && !StructIsEmpty(application[$wheels.appKey].mixins)) { $wheels.metaData = GetMetadata(variablesScope.this); + // Classify by dotted-path segment, not unanchored substring: an + // unanchored FindNoCase("controllers", ...) also matched component + // names like "app.models.ControllerStats" and handed them the + // controller mixin set (di-packages:12). if (StructKeyExists($wheels.metaData, "displayName")) { $wheels.className = $wheels.metaData.displayName; - } else if (findNoCase("controllers", $wheels.metaData.fullname)){ + } else if (ListFindNoCase($wheels.metaData.fullname, "controllers", "./\")) { $wheels.className = "controller"; - } else if (findNoCase("models", $wheels.metaData.fullname)){ + } else if (ListFindNoCase($wheels.metaData.fullname, "models", "./\")) { $wheels.className = "model"; - } else if (findNoCase("tests", $wheels.metaData.fullname)){ + } else if (ListFindNoCase($wheels.metaData.fullname, "tests", "./\")) { $wheels.className = "test"; } else { $wheels.className = Reverse(SpanExcluding(Reverse($wheels.metaData.name), ".")); diff --git a/vendor/wheels/events/onrequestend/debug.cfm b/vendor/wheels/events/onrequestend/debug.cfm index 24df4fc6fa..5380b19e3f 100644 --- a/vendor/wheels/events/onrequestend/debug.cfm +++ b/vendor/wheels/events/onrequestend/debug.cfm @@ -409,6 +409,29 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo + + + +
+
Deprecations
+
+ +

+ #EncodeForHTML(local.dw.message)# + + Migration guide + +

+
+
+
+
diff --git a/vendor/wheels/tests/_assets/mixins_classification/controllers/Visitors.cfc b/vendor/wheels/tests/_assets/mixins_classification/controllers/Visitors.cfc new file mode 100644 index 0000000000..956a335a06 --- /dev/null +++ b/vendor/wheels/tests/_assets/mixins_classification/controllers/Visitors.cfc @@ -0,0 +1,4 @@ +// Fixture for pluginsMixinClassificationSpec (di-packages:12): a component under +// a real "controllers" path segment — must keep classifying as a controller. +component { +} diff --git a/vendor/wheels/tests/_assets/mixins_classification/models/ControllerStats.cfc b/vendor/wheels/tests/_assets/mixins_classification/models/ControllerStats.cfc new file mode 100644 index 0000000000..7adc604f83 --- /dev/null +++ b/vendor/wheels/tests/_assets/mixins_classification/models/ControllerStats.cfc @@ -0,0 +1,6 @@ +// Fixture for pluginsMixinClassificationSpec (di-packages:12): a component that +// lives under a "models" path segment but whose name starts with "ControllerS", +// so an unanchored FindNoCase("controllers", fullname) misclassifies it as a +// controller. Dotted-segment matching must classify it as a model. +component { +} diff --git a/vendor/wheels/tests/specs/global/deprecatedHelperSpec.cfc b/vendor/wheels/tests/specs/global/deprecatedHelperSpec.cfc new file mode 100644 index 0000000000..f4458a0517 --- /dev/null +++ b/vendor/wheels/tests/specs/global/deprecatedHelperSpec.cfc @@ -0,0 +1,70 @@ +// upgrade-docs:6 — shared $deprecated() helper: one policy for logging and +// registering deprecation warnings so they are visible to running apps +// (application[appKey].deprecationWarnings, rendered by the debug panel). +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + // Shared carrier struct: sibling closures (beforeEach/afterEach) must not + // share state through bare unscoped names (CLAUDE.md anti-pattern 10) — + // they read the outer struct reference and mutate its fields instead. + var state = {hadOriginalWarnings = false, originalWarnings = []} + + describe("$deprecated shared helper", () => { + + beforeEach(() => { + state.hadOriginalWarnings = StructKeyExists(application.wheels, "deprecationWarnings") + if (state.hadOriginalWarnings) { + state.originalWarnings = application.wheels.deprecationWarnings + } + application.wheels.deprecationWarnings = [] + }) + + afterEach(() => { + if (state.hadOriginalWarnings) { + application.wheels.deprecationWarnings = state.originalWarnings + } else { + StructDelete(application.wheels, "deprecationWarnings") + } + }) + + it("records feature, message and url in the application registry", () => { + g.$deprecated( + feature = "wheelstest-probe", + message = "Probe message.", + docUrl = "https://example.com/migrate" + ) + expect(ArrayLen(application.wheels.deprecationWarnings)).toBe(1) + var entry = application.wheels.deprecationWarnings[1] + expect(entry.feature).toBe("wheelstest-probe") + expect(entry.message).toBe("Probe message.") + expect(entry.url).toBe("https://example.com/migrate") + }) + + it("registers a feature only once per application", () => { + g.$deprecated(feature = "wheelstest-dedupe", message = "First.") + g.$deprecated(feature = "wheelstest-dedupe", message = "Second.") + expect(ArrayLen(application.wheels.deprecationWarnings)).toBe(1) + expect(application.wheels.deprecationWarnings[1].message).toBe("First.") + }) + + it("registers distinct features separately", () => { + g.$deprecated(feature = "wheelstest-a", message = "A.") + g.$deprecated(feature = "wheelstest-b", message = "B.") + expect(ArrayLen(application.wheels.deprecationWarnings)).toBe(2) + }) + + it("creates the registry lazily when it does not exist yet", () => { + StructDelete(application.wheels, "deprecationWarnings") + g.$deprecated(feature = "wheelstest-lazy", message = "Lazy.") + expect(StructKeyExists(application.wheels, "deprecationWarnings")).toBeTrue() + expect(ArrayLen(application.wheels.deprecationWarnings)).toBe(1) + }) + + }) + + } + +} diff --git a/vendor/wheels/tests/specs/pluginsDeprecationMessagingSpec.cfc b/vendor/wheels/tests/specs/pluginsDeprecationMessagingSpec.cfc new file mode 100644 index 0000000000..04f8071773 --- /dev/null +++ b/vendor/wheels/tests/specs/pluginsDeprecationMessagingSpec.cfc @@ -0,0 +1,60 @@ +// Review follow-ups di-packages:7, upgrade-docs:2 and upgrade-docs:3: keep the +// routine plugin-load trace out of the security log, keep the deprecation +// messages pointing at live versioned guide URLs, and keep the remediation text +// aligned with the shipped package system (packages install into vendor/ — +// there is no packages/ staging directory). +component extends="wheels.WheelsTest" { + + function run() { + + describe("Plugins.cfc deprecation messaging and log routing", () => { + + it("routes the plugin-load trace to the standard wheels log, not the security log", () => { + var source = FileRead(ExpandPath("/wheels/Plugins.cfc")); + expect(FindNoCase("wheels_security", source) GT 0).toBeFalse( + "Plugins.cfc must not write routine plugin-load entries to the " + & "wheels_security log — startup noise pollutes the security " + & "audit trail. Use file=""wheels"" like PackageLoader.cfc " + & "(di-packages:7)." + ); + }); + + it("does not link to dead documentation URLs", () => { + var source = FileRead(ExpandPath("/wheels/Plugins.cfc")); + expect(FindNoCase("guides.wheels.dev/docs/", source) GT 0).toBeFalse( + "Plugins.cfc must not link unversioned guides.wheels.dev/docs/ " + & "paths — guides URLs are versioned (e.g. " + & "guides.wheels.dev/v4-0-0/...) and the unversioned forms 404 " + & "(upgrade-docs:2)." + ); + expect(FindNoCase("wheels.dev/docs/packages", source) GT 0).toBeFalse( + "Plugins.cfc must not link wheels.dev/docs/packages — the live " + & "page is guides.wheels.dev/v4-0-0/digging-deeper/packages/ " + & "(upgrade-docs:2)." + ); + // Positive guard: at least one live versioned guide URL remains. + expect(FindNoCase("https://guides.wheels.dev/v4-0-0/", source) GT 0).toBeTrue( + "Plugins.cfc deprecation messages must point at live versioned " + & "guide URLs (upgrade-docs:2)." + ); + }); + + it("does not describe the abandoned packages/ staging design or use future tense", () => { + var source = FileRead(ExpandPath("/wheels/Plugins.cfc")); + expect(FindNoCase("Move them to packages/", source) GT 0).toBeFalse( + "Plugins.cfc must not tell users to move plugins to a packages/ " + & "staging directory — the shipped loader discovers packages " + & "from vendor/ only (upgrade-docs:3)." + ); + expect(FindNoCase("will be deprecated", source) GT 0).toBeFalse( + "Plugins.cfc deprecation text must use present tense — plugins " + & "are deprecated as of Wheels 4.0, not at some future point " + & "(upgrade-docs:3)." + ); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/pluginsMixinClassificationSpec.cfc b/vendor/wheels/tests/specs/pluginsMixinClassificationSpec.cfc new file mode 100644 index 0000000000..92424bf781 --- /dev/null +++ b/vendor/wheels/tests/specs/pluginsMixinClassificationSpec.cfc @@ -0,0 +1,59 @@ +// di-packages:12 — $initializeMixins must classify components by dotted-path +// segment, not by unanchored substring. The old FindNoCase("controllers", ...) +// matched component NAMES like "ControllerStats" under app.models and handed +// them the controller mixin set. +component extends="wheels.WheelsTest" { + + function run() { + + // Shared carrier struct: sibling closures (beforeEach/afterEach) must not + // share state through bare unscoped names (CLAUDE.md anti-pattern 10) — + // they read the outer struct reference and mutate its fields instead. + var state = {originalMixins = {}} + + describe("$initializeMixins component classification", () => { + + beforeEach(() => { + state.originalMixins = application.wheels.mixins + application.wheels.mixins = { + controller = {"$wheelstestClassificationProbe" = "controller"}, + model = {"$wheelstestClassificationProbe" = "model"} + } + }) + + afterEach(() => { + application.wheels.mixins = state.originalMixins + }) + + it("classifies a model whose name contains 'Controller' as a model", () => { + var target = CreateObject( + "component", + "wheels.tests._assets.mixins_classification.models.ControllerStats" + ) + var scopeStruct = {} + scopeStruct["this"] = target + // CreateObject skips init() so the plugin-loading constructor side effects + // (e.g. $checkPluginsDeprecation appending to application.wheels.deprecationWarnings) + // do not leak across this spec. + CreateObject("component", "wheels.Plugins").$initializeMixins(scopeStruct) + expect(scopeStruct).toHaveKey("$wheelstestClassificationProbe") + expect(scopeStruct.$wheelstestClassificationProbe).toBe("model") + }) + + it("still classifies components under a controllers segment as controllers", () => { + var target = CreateObject( + "component", + "wheels.tests._assets.mixins_classification.controllers.Visitors" + ) + var scopeStruct = {} + scopeStruct["this"] = target + CreateObject("component", "wheels.Plugins").$initializeMixins(scopeStruct) + expect(scopeStruct).toHaveKey("$wheelstestClassificationProbe") + expect(scopeStruct.$wheelstestClassificationProbe).toBe("controller") + }) + + }) + + } + +} diff --git a/vendor/wheels/tests/specs/pluginsModernSpec.cfc b/vendor/wheels/tests/specs/pluginsModernSpec.cfc index 3b45d7b7df..bffcddb2b0 100644 --- a/vendor/wheels/tests/specs/pluginsModernSpec.cfc +++ b/vendor/wheels/tests/specs/pluginsModernSpec.cfc @@ -166,6 +166,45 @@ component extends="wheels.WheelsTest" { StructDelete(application, "$wheelstestLifecycleLog") }) + it("keeps live application references intact after onPluginLoad", function() { + var originalPluginComponentPath = application.wheels.pluginComponentPath + StructDelete(application, "$wheelstestLifecycleLog") + + // A struct placed in the application scope before plugins load. + // The onPluginLoad context must be a shallow copy: the old + // Duplicate(application) implementation wrote a deep clone back + // over this key, forking it from any variable still holding the + // original reference (di-packages:10). + var marker = {value = "original"} + application.$wheelstestSharedRef = marker + + // try/finally so a failing assertion can't leak $wheelstestSharedRef + // (or the mutated pluginComponentPath) into subsequent tests. + try { + var config = { + path = "wheels", + fileName = "Plugins", + method = "$init", + pluginPath = "/wheels/tests/_assets/plugins/lifecycle", + deletePluginDirectories = false, + overwritePlugins = false, + loadIncompatiblePlugins = true + } + application.wheels.pluginComponentPath = "/wheels/tests/_assets/plugins/lifecycle" + + var PluginObj = $pluginObj(config) + + // Mutating through the pre-load reference must be visible through + // the application scope — they are the same struct. + marker.value = "mutated" + expect(application.$wheelstestSharedRef.value).toBe("mutated") + } finally { + application.wheels.pluginComponentPath = originalPluginComponentPath + StructDelete(application, "$wheelstestSharedRef") + StructDelete(application, "$wheelstestLifecycleLog") + } + }) + it("does not inject lifecycle hooks as mixins", function() { originalPluginComponentPath = application.wheels.pluginComponentPath StructDelete(application, "$wheelstestLifecycleLog") diff --git a/vendor/wheels/view/links.cfc b/vendor/wheels/view/links.cfc index 96ef5c7947..83cef830be 100644 --- a/vendor/wheels/view/links.cfc +++ b/vendor/wheels/view/links.cfc @@ -236,12 +236,14 @@ component { boolean pageNumberAsParam, any encode ) { - // One-time per-request deprecation warning (#2714) — mirrors $checkPluginsDeprecation() in Plugins.cfc. + // Per-request short-circuit (#2714); the once-per-application logging and + // registration policy lives in the shared $deprecated() helper. if (!StructKeyExists(request.wheels, "$paginationLinksDeprecationLogged")) { request.wheels.$paginationLinksDeprecationLogged = true; - WriteLog( - type = "warning", - text = "[Wheels] paginationLinks() is deprecated and will be removed in a future release. Use paginationNav() instead (or compose the individual helpers: firstPageLink/previousPageLink/pageNumberLinks/nextPageLink/lastPageLink). See https://github.com/wheels-dev/wheels/issues/1930" + $deprecated( + feature = "paginationLinks", + message = "paginationLinks() is deprecated and will be removed in a future release. Use paginationNav() instead (or compose the individual helpers: firstPageLink/previousPageLink/pageNumberLinks/nextPageLink/lastPageLink).", + docUrl = "https://github.com/wheels-dev/wheels/issues/1930" ); }