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
6 changes: 3 additions & 3 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>)"
});
}
arrayAppend(checks, {
Expand All @@ -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 <name>)"
});
// Matches both quote styles and the silent wheels.Testbox shim
// (deprecated alias of wheels.WheelsTest, removal target 5.0) —
Expand Down
58 changes: 58 additions & 0 deletions vendor/wheels/Global.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 39 additions & 16 deletions vendor/wheels/Plugins.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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/"
);
}
}
Expand Down Expand Up @@ -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 <name>` for published packages).",
docUrl = "https://guides.wheels.dev/v4-0-0/digging-deeper/packages/"
);
}
}
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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), "."));
Expand Down
23 changes: 23 additions & 0 deletions vendor/wheels/events/onrequestend/debug.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,29 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
</div>
</cfif>
</cfif>
<!---
Deprecation warnings collected via the shared $deprecated() helper.
application.wheels (not $appKey()) is correct here: application.$wheels only
exists during onapplicationstart, and its final line reassigns the same struct
reference to application.wheels — so init-time registrations are already
visible under application.wheels by the time any onrequestend runs.
--->

<cfif StructKeyExists(application.wheels, "deprecationWarnings") AND ArrayLen(application.wheels.deprecationWarnings)>
<div class="wdb-section">
<div class="wdb-section-title" style="color:##f9e2af;">Deprecations</div>
<div style="color:##f9e2af;font-size:12px;">
<cfloop array="#application.wheels.deprecationWarnings#" index="local.dw">
<p>
#EncodeForHTML(local.dw.message)#
<cfif StructKeyExists(local.dw, "url") AND Len(local.dw.url)>
<a href="#EncodeForHTMLAttribute(local.dw.url)#" style="color:##89b4fa;" target="_blank" rel="noopener">Migration guide</a>
</cfif>
</p>
</cfloop>
</div>
</div>
</cfif>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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 {
}
70 changes: 70 additions & 0 deletions vendor/wheels/tests/specs/global/deprecatedHelperSpec.cfc
Original file line number Diff line number Diff line change
@@ -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)
})

})

}

}
60 changes: 60 additions & 0 deletions vendor/wheels/tests/specs/pluginsDeprecationMessagingSpec.cfc
Original file line number Diff line number Diff line change
@@ -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)."
);
});

});

}

}
Loading
Loading