Skip to content

Wheels 4.0.6

Latest

Choose a tag to compare

@github-actions github-actions released this 21 Aug 00:49
5928131

Added

  • wheels generate auth — one-command authentication scaffold built on the wheels.auth primitives (#3155). The default session strategy emits a User model with PBKDF2 password hashing via the passwordHasher service, Sessions/Passwords/Registrations controllers (registration on by default; disable with --no-registration), CSRF-safe startFormTag views, a create-users migration with a unique email index, marked route/service/strategy blocks injected into config/routes.cfm, config/services.cfm, and app/events/onapplicationstart.cfm, plus generated app specs. --strategy=token and --strategy=jwt emit an api/Sessions.cfc controller (opaque SHA-256-digested bearer tokens, or JWTs signed with WHEELS_JWT_SECRET that fail loudly at startup when the secret is missing). Generated code is code you own: every file carries a stamped header, and re-running with --force regenerates files and replaces the injected blocks in place without duplicating them.
  • Added wheels.auth.PasswordHasher, a cross-engine password hashing service using PBKDF2-HMAC-SHA256 (600,000 iterations by default per OWASP 2023+, 16-byte SecureRandom salt, 256-bit derived key) with a self-describing modular-crypt storage format ($pbkdf2-sha256$i=<iterations>$<base64(salt)>$<base64(derivedKey)>). verify() compares digests in constant time and returns false (never throws) on malformed input; needsRehash() enables transparent work-factor upgrades. Hashes are byte-identical across Lucee, Adobe CF, and BoxLang, so they survive engine migrations. Groundwork for wheels generate auth (#3155, #2962).
  • Authorization policy layer: new wheels.Policy base class (default-deny — every standard action denies and scope() returns a no-rows chain) with app/policies/<ModelName>Policy.cfc resolution, plus authorize() / can() / policyScope() controller-and-view helpers. authorize() throws Wheels.NotAuthorized (HTTP 403, mapped like Wheels.RecordNotFound → 404) and returns the record on allow; a missing policy class throws Wheels.Policy.NotDefined in development/testing and silently denies in production. The current user resolves through the currentUser DI service, then a configured authenticator strategy's currentUser(), then guest — customizable by overriding $currentUserForPolicy(). Includes a wheels generate policy <Model> CLI generator and a new Authorization Policies guide (#3156, part of #2962)
  • Added a pluggable storage-disk abstraction under wheels.storage with LocalDisk and S3Disk drivers behind a uniform put/get/exists/delete/url/signedUrl interface, resolved by name through StorageManager. S3 access — including presigned, expiring URLs — uses a from-scratch SigV4 signer over plain cfhttp (no AWS SDK, no JARs) (#3157).
  • Added an includeCalculated argument to findAll(), findOne(), and findByKey() for additively opting a select=false calculated SQL property back into a single finder — e.g. model("User").findAll(includeCalculated="fullName"). Unlike select, it merges the named calculated properties on top of the default column list rather than replacing it, so the rest of the record is still returned. Unknown names throw Wheels.CalculatedPropertyNotFound in development/testing and are ignored in production (#3252)
  • Capability-based engine degradation: engine adapters expose supportsImageInfo() and a cached plain-data getCapabilities() aggregate, and imageTag() skips its width/height dimension probe on engines whose adapter reports no image support — rendering the tag without dimensions instead of erroring. New wheels.wheelstest.EngineCapabilities JVM probes (hasJvmClassLoading(), canWriteSystemProperties()) let browser tests skip cleanly with a typed Wheels.BrowserJvmUnavailable error on engines without a JVM instead of failing mid-classloader setup. RustCFML engine detection fixed: the server.coldfusion.productName marker is now checked before the Lucee branch (RustCFML exposes a Lucee-impersonating server.lucee struct), so it resolves to its dedicated adapter again — which now reports cfcache support (implemented in RustCFML v0.417) and ships a defensive zero-dimension imageInfo() fallback.
  • select(), include(), group(), distinct(), and forUpdate() can now start a query-builder chain directly on the model class (e.g. model("Person").select("id,firstName").where("department", "engineering").get()), matching where() and the other entry-position builder methods. forUpdate() is also available when transitioning from a scope chain. (#3346)
  • In the development environment, a refused ?reload=true is no longer a silent no-op: the debug bar now renders an inline notice explaining why the reload did not fire and what to do. Three cases are distinguished — reloadPassword is empty (URL reload disabled, fail-closed since 4.0.4, with the set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', '')) fix inline), the password URL parameter is missing, and a deliberately generic "refused" for wrong-password or rate-limited attempts (pointing at wheels_security.log without distinguishing the two, so the notice adds no oracle on top of the constant-time compare). The reload gate in all four public/Application.cfc template copies records the refusal reason in request.wheels.reloadRefusedReason (pinned by a structural parity spec); message text and the development-only gate live framework-side in the debug bar. Other environments are unchanged: silent no-op plus wheels_security.log, exactly as before (#3311)

Changed

  • vendor/wheels/Global.cfc is no longer a 4,800-line monolith: helpers now live in focused vendor/wheels/global/*.cfm includes compiled into the component. $include stays on Global.cfc and collapses ../../#eventPath# (Application.cfc onAbort / onApplicationEnd) to the /app/events mapping so onabort.cfm and onapplicationend.cfm resolve after the split. Component-body /wheels/global/*.cfm includes fall back to mapping-free paths when Adobe CF 2023 applicationStop() drops THIS.mappings (authorized reload was HTTP 500 on locking.cfm). $abortInvalidRequest measures request depth against ExpandPath("/wheels/Global.cfc"). The public $-prefixed mixin surface is unchanged (#3241)
  • wheels new generates a richer default home page: a runtime status line (Wheels version, engine, database, environment) plus a Next-steps command guide, replacing the bare two-line placeholder — surfacing the onboarding content from the redesigned framework welcome page where users actually land (#2098)

Performance

  • Model, controller, and mapper object creation no longer re-scans the framework mixin folders (a directory listing plus a createObject and getMetaData per file) on every materialization. The mixin-integration plan is now built once per application and reused, and the per-method $willBeOverriddenByMixin lookup is precomputed — cutting model-instance creation roughly in half (every new() and every finder row was paying the full cost). This is the regression behind slow test-suite and request times reported on 4.0.x (#3213)
  • wheels.channel.DatabaseAdapter.cleanup() now pushes the maxRows bound into dialect SQL (SELECT TOP n on SQL Server, FETCH FIRST n ROWS ONLY on Oracle, LIMIT n everywhere else) via the new $applyRowBound() helper, so bounded retention passes do an index-assisted top-n read instead of materializing the whole expired backlog and truncating it client-side. The driver-level maxrows option is kept as belt-and-braces.

Fixed

  • The application template's this.wheels.rootPath now anchors to GetCurrentTemplatePath() (the public/ front-controller directory) instead of GetBaseTemplatePath() (whatever file was originally requested). When a request bootstrapped under a subfolder — e.g. the test runner — the old base-template anchor produced an unstable path, and because rootPath seeds this.name via Hash(rootPath), that silently split one app across two application scopes (the "reload=true fixes it" symptom). The value is identical for a normal front-controller request, so existing apps are unaffected (#3025, refs #2887)
  • Custom validation condition/unless expressions that call a model method with a positional argument — e.g. condition="this.propertyIsPresent('productid')" — now evaluate correctly instead of throwing The parameter [property] ... is required but was not passed in. The condition argument parser previously understood only named arguments (key='val') and silently dropped positional ones; it now maps positional arguments onto the target method's declared parameter names (#3238)
  • Nested include strings whose parenthesized intermediate is a belongsTo (e.g. findAll(include="SecondaryContact(User)")) again generate flat sibling joins, keeping the root FROM table in scope for every ON condition. The issue #449 HABTM/through parenthesized-grouping heuristic was over-firing on plain belongsTo-chain includes, producing a nested join expression that MySQL rejected with Unknown column '<table>.<column>' in 'on clause' — a regression from Wheels 2. The grouping now consults the association metadata and only nests for a genuine hasMany/hasOne bridge, so HABTM/through includes still nest as before (#3245)
  • /wheels/app/tests now renders the TestBox-style HTML report in a browser for apps that use the built-in fallback test runner, matching /wheels/core/tests. The endpoint previously returned raw JSON for the no-format default and ?format=html; ?format=json, ?format=txt, and ?format=junit are unchanged, and an unrecognized ?format= value still returns no body as before (#3251).
  • Fixed an Adobe ColdFusion Routines cannot be declared more than once HTTP 500 in the shared test-report template (vendor/wheels/tests/html.cfm) that broke the format=html report for both the app and core test runners on repeated requests. The recursive helper is now declared as a variables-scoped function expression, matching the core runner's existing convention (#3251).
  • The scaffolded tests/runner.cfm now resolves its include of the built-in app test runner through $resolveSubpathInclude() instead of a hardcoded absolute /wheels/tests/app-runner.cfm path. Under a URL subpath / CommandBox multi-subfolder install the bare /wheels mapping did not resolve, so /wheels/app/tests and wheels test failed; the include is now prefixed with the app's resolved webPath and works at the web root and under a subfolder alike (#3251, refs #2887)
  • Fixed the wheels.dev header and footer logo being invisible in dark mode — the lockup logo variant now swaps to the white lockup under prefers-color-scheme: dark via a pure-CSS toggle (#3264)
  • The Refresh visual baselines workflow (.github/workflows/refresh-visual-baselines.yml) no longer hard-fails when the dispatched branch rejects direct pushes (GH013: Changes must be made through a pull request, e.g. develop). Delivery now lives in tools/gh-open-refresh-baseline-pr.sh: branches that allow it still get the direct push (unchanged feature-branch flow), and protected branches get a chore/refresh-baseline-* PR instead — left for a maintainer to merge, since GITHUB_TOKEN-authored PRs never trigger the required checks. The job now also grants pull-requests: write (#3283)
  • Migrator column helpers no longer lose their declared default values on Adobe ColdFusion. A parameter declared as <type> default (e.g. string default = "newid()") is parsed by Adobe as a parameter named string, silently discarding the default name and its declared value — so t.uniqueidentifier() emitted DDL with no DEFAULT clause and t.float() lost its default="" / allowNull=true outlier defaults. The type keyword has been dropped from every default parameter declaration in Migration.cfc, TableDefinition.cfc, Abstract.cfc, the MySQL/SQLite migrators and DatabaseMigratorAdapterInterface.cfc (#3302)
  • $evaluateExpression() now evaluates built-in-function expressions through the BoxLang runtime on BoxLang. BoxLang ships no Evaluate() BIF, so every expression that fell through to the built-in branch returned Error evaluating expression: Function [Evaluate] not found instead of its result (#3302)
  • Channel database adapter cleanup(maxRows=...) no longer sets the driver-level maxrows query option when the row bound has already been pushed into dialect SQL. On BoxLang the option reaches the PostgreSQL driver as setLargeMaxRows(), which pgjdbc does not implement, so the bounded retention pass threw and reported zero rows deleted on PostgreSQL and CockroachDB — leaving expired wheels_events rows to accumulate (#3302)
  • CockroachDBTransactionSpec now declares an isolation level on the outer transaction that wraps updateAll(transaction="rollback"). Adobe ColdFusion rejects a nested cftransaction whose isolation level differs from its parent's, and the resulting exception escaped invokeWithTransaction before its catch could clear request.wheels.transactions, leaving the connection permanently marked as "transaction already open" — so every later model call in that request silently skipped its own transaction and OuterTransactionSignalSpec's rollback assertion failed as a knock-on (#3302)
  • $parseInsertColumnList() now uses one implementation on every engine instead of forking on a BoxLang check whose non-BoxLang branch dropped the comma delimiters when it ran on BoxLang. The unified regex form also preserves spaces inside quoted identifiers such as [order date], which the previous ReplaceList form stripped (#3302)
  • LocalDisk.put() now writes content as bytes rather than as a string, so get() round-trips exactly what was stored. Adobe ColdFusion 2025's FileWrite() appends a trailing line feed to simple values, which added a byte to every stored object and corrupted binary payloads (#3302)
  • Helper functions included into wheels.Public by $init() are now reachable on the component's this scope on every engine. The runtime include placed them in variables only, so external callers hit "has no function with name" on Lucee 6, Adobe 2023 and Adobe 2025 while the same call worked on Lucee 7 and BoxLang (#3302)
  • invokeWithTransaction() now clears request.wheels.transactions when the cftransaction fails to open, not only when the wrapped method throws. A rejected isolation level, a nested-isolation mismatch, or a dead connection previously left the connection marked "transaction already open" for the rest of the request, so every later model call silently ran with no transaction at all (#3302)
  • Overriding a controller or view helper now registers the framework original as super<name>, matching the model layer. Following the "Overriding Core Methods" guide — override linkTo(), call superLinkTo() — produced a 500, because Controller.cfc's $integrateFunctions() only aliased super<name> for names a registered plugin/package mixin overrode, while Model.cfc's aliased it for any name already present on the target. App-level overrides of controller and view helpers silently got nothing. The manual variables.coreLinkTo = CreateObject("component", "wheels.view.links").linkTo workaround is no longer needed. Controllers that override nothing gain no extra keys — no two framework mixins contribute the same name, so the branch only fires on a genuine override (#3325, from discussion #3323)
  • findAll(include="...") no longer copies a nested association's INNER JOIN into unrelated sibling joins. When a nested group followed one or more shallow associations (e.g. include="comments,classifications(tag)"), the issue #449 parenthesized grouping spliced the nested INNER JOIN into every preceding LEFT OUTER JOIN, so those joins referenced a table the query had not introduced yet — Oracle rejected it with ORA-00904: invalid identifier, MySQL with Unknown column '<table>.<column>' in 'on clause'. Each INNER JOIN is now scoped to the single association it is nested under, taken from the include's association tree rather than re-derived from the generated SQL text. Reported with a working patch by Mike Grogan (#3334)
  • Behaviour change: include order no longer changes the SQL a query generates. Grouping used to be gated on an anchored pattern over the include string that only matched when the nested group came last, so include="a(b),c" and include="c,a(b)" produced structurally different joins for the same query. In the nested-first form the nested INNER JOIN was emitted at the root, which demoted the sibling LEFT OUTER JOIN to an inner join and silently dropped parent rows that had no associated record. Both orderings now emit the same joins, so a query written in the nested-first form can return more rows than before — the rows a hasMany/hasOne include is meant to preserve. Pass joinType="inner" on the association if the filtering was intentional (#3334)
  • The per-request finder cache is now namespaced under request.wheels.$queryCache[ModelName] instead of sitting directly in request.wheels[ModelName]. Because CFML struct keys are case-insensitive, the flat layout let a model name alias onto a framework-owned request key: an app with a model named Tenant — the documented name for the control-plane model in a database-per-tenant app — shared one key between its query cache and request.wheels.tenant. Two silent failures followed. $clearRequestCache(), which runs after every create/update/delete/bulk operation, wiped the resolved tenant to {}, so every tenant-scoped query later in that request fell back to the control-plane datasource and wrote to the wrong database with no error. And a Tenant finder running before TenantResolver (the obvious shape for a subdomain→tenant directory) populated request.wheels.tenant with query-cache entries, making an unresolved request look resolved to any IsDefined("request.wheels.tenant") guard. Caching behaviour is otherwise unchanged (#3336)
  • tenant() now treats a value on request.wheels.tenant as an active tenant only when it carries a non-empty dataSource — the same test $tenantDataSource() already applies before routing a query — and returns an empty struct otherwise. Previously it handed back whatever occupied the key, so a malformed value read as a resolved tenant to any IsDefined("request.wheels.tenant") or truthiness guard. Relatedly, wheels.middleware.TenantResolver now deletes any pre-existing value on the key when its resolver returns no match, instead of leaving a stale or foreign one to outlive resolution for the remainder of the request. Together these downgrade a malformed tenant context from wrong behaviour to a no-op. Every framework producer (switchTenant(), TenantResolver, Job.$restoreTenantContext(), TenantMigrator) already guarantees a non-empty dataSource, so correctly-resolved tenants are unaffected (#3336)
  • Association foreign-key defaults now resolve either reference-column convention instead of only the legacy <modelName><key> one. useUnderscoreReferenceColumns (framework default false, wheels new template default true) makes the migrator emit user_id, but the model layer derived userid unconditionally — so a stock new app that declared belongsTo("user") without an explicit foreignKey threw key [userid] doesn't exist the first time any include= traversed the association. The default is now resolved against the columns that actually exist on whichever side owns the foreign key (belongsTo looks at the declaring model, hasMany/hasOne at the associated one), so both conventions work — including apps that enabled the flag mid-life and hold a mix of both shapes. This is deliberately schema-driven rather than reading the setting: references() re-reads the flag on every call while the model-side default is memoized for the application lifetime, so a flag-driven default would let a runtime flip change migrations without changing models. It is also strictly error-reducing — the underscore form is only consulted when the legacy form is absent, which is a case that used to throw. Polymorphic associations are not covered; they pin their foreign key at registration time, before the schema is available, and still need an explicit foreignKey= under the underscore convention (#3337)
  • An association whose derived default foreign key matches no column on the model that owns it now throws Wheels.AssociationForeignKeyNotFound at association-resolution time, naming the association, both candidate column shapes, and foreignKey= as the fix. Previously this surfaced as key [userid] doesn't exist from deep inside the join builder, which named neither the association nor the argument that resolves it. Development and testing only, and only for defaults Wheels derived itself — an explicit foreignKey= is left alone (#3337)
  • Pagination handles are now stored under request.wheels.$pagination[handle] instead of directly in request.wheels[handle]. Handles are caller-supplied names, so the flat layout put arbitrary user input in the same case-insensitive keyspace as framework-owned request state, and the collision ran both ways. Writing: setPagination(handle="tenant") replaced the resolved tenant context with a pagination struct, and handle="$queryCache" did the same to the per-request finder cache — silently, since neither is validated. Reading: pagination() only checks that a handle exists when showErrorInformation is on, so in production an unknown handle that happened to name a framework key returned that key's struct as though it were pagination data. request.wheels currently holds around thirty-five framework-owned keys — including params, execution, currentRoute, transactions, flashKeep and exception — every one of which was reachable this way. Handles now resolve only inside their own sub-struct, so neither direction can cross over. Wheels.QueryHandleNotFound behaviour is unchanged (#3339)
  • The PostgreSQL adapter no longer picks up columns from other schemas when introspecting a table. cfdbinfo(type="columns") applies no schema restriction, so JDBC matched the table name across every schema on the connection — and PostgreSQL, YugabyteDB and CockroachDB all ship catalog views named sequences, tables, columns, views, triggers and more. An application table sharing one of those names silently collected a second batch of phantom columns typed "information_schema"."sql_identifier", which no $getType() case matched, and the model failed to initialise with the opaque key [RV] doesn't exist. Rows from information_schema, pg_catalog, crdb_internal and pg_extension are now dropped — inside the cacheDatabaseSchema memo, so the filtering costs one pass per datasource+table per application lifetime. Reported against YugabyteDB (PostgreSQL 15 wire protocol) and reproducible on stock PostgreSQL (#3349)
  • The migrator's column lookup got the same guard. vendor/wheels/migrator/Base.cfc calls $dbinfo(type="columns") directly rather than through the model adapter, so changeTable(name="sequences") adding a column named data_type or start_value could see the catalog view's column and treat it as already present (#3349)
  • An unmapped PostgreSQL column type now throws Wheels.UnknownColumnType naming the type, instead of key [RV] doesn't exist from an unassigned return variable — the failure names the column type and points at catalog bleed as the likely cause rather than reading like a framework bug (#3349)
  • validatesUniquenessOf(property="x", scope="y") now returns a validation result instead of throwing Component [Model] has no accessible Member with name [y] when the scope property was never assigned. Building the uniqueness WHERE clause dereferenced every scope property without an existence guard, and a scope property is easy to leave absent rather than empty: $setDefaultValues() only seeds properties with an explicit property() mapping, so a column with a database-level default but no mapping is missing from a new()-ed object entirely. An absent scope property is now treated as blank, matching what a present-but-empty one has always done — including the existing conversion of an empty numeric scope to IS NULL. The property(name="<scopeProperty>", defaultValue="") workaround is no longer needed (#3350)
  • A background job whose jobClass cannot be resolved now throws Wheels.JobClassNotFound naming the class, the queue row, and the likely causes, instead of the engine's bare component not found. wheels_jobs.jobClass is written from GetMetadata(this).name on enqueue and resolved as a component path on drain, so the failure appears as "component not found" for a class that plainly exists on disk — which sends people to look at mappings and deployment rather than at the persisted string. Component paths are case-sensitive on Linux but not on macOS or Windows, so a casing mismatch resolves in development and fails on a production redeploy, long after the row was written. A path that resolves to something without a perform() method now throws Wheels.InvalidJobClass rather than failing later inside job execution. Both processing paths (Job.$processJob and JobWorker.$executeJob) share the check (#3351)
  • Verified across every engine: the jobClass string persisted on enqueue always round-trips. Lucee and BoxLang derive the metadata name from the file, so a miscased path still yields the canonical name; Adobe's component resolver is case-sensitive independently of the filesystem, so a miscased path does not construct at all. Either way a caller's miscasing cannot reach wheels_jobs.jobClass. Pinned by JobClassRoundTripSpec, which runs on all five engines rather than assuming the invariant (#3351)
  • wheels test no longer dies with Read timed out on a suite that takes more than about two minutes. The CLI's HTTP client applied a hardcoded 120-second read timeout to every request, which is right for the short request/response bridge commands but is a hard ceiling on how big a suite the test command can run — and it produced no result at all, not a failure report, so a passing suite was indistinguishable from a hung app. The budget is now 900 seconds by default and configurable with --timeout=<seconds> or WHEELS_TEST_TIMEOUT. When it is still exceeded, the message says which side gave up, that the specs may well have passed, and how to give it longer or scope the run. The browser-test runner, which makes the same long-running call, got the same budget (#3352)
  • tools/test-local.sh writes its results to a per-checkout file instead of a single fixed /tmp path, so two working copies running the suite no longer overwrite each other — which silently turned a develop-vs-branch comparison into two copies of the same run. It also clears the file before the request, so a run that fails outright (HTTP 000, typically a server that is not up yet) can no longer leave the previous run's results behind to be read as if they were current. Override with WHEELS_TEST_RESULT_FILE (#3352)
  • A CSRF cookie written under the legacy bare AES (ECB) default is now always read via the legacy fallback, instead of roughly 1 time in 256 being reported as corrupted. $decryptCsrfCookieValue() tried the configured algorithm and fell back to bare AES only from its catch block — treating "did not throw" as "decrypted correctly". Decrypting an ECB ciphertext under AES/CBC/PKCS5Padding throws only when the trailing plaintext bytes fail padding validation, and they pass by chance about 1 time in 256, so Decrypt() returned garbage and the fallback never ran. The decrypt result is now validated before it is accepted: this cookie's plaintext is always the JSON written by $generateCookieAuthenticityToken(), so a non-JSON result means the wrong algorithm was used and the legacy attempt still runs. AES/GCM/NoPadding is authenticated and always threw, so only the engines that fall back to CBC were affected. Fails closed either way — a genuinely corrupt cookie is still reported exactly as before (#3361)
  • Docs, --help, and the packages website now agree that the install verb is wheels packages addwheels packages install is intercepted by LuCLI before the Wheels module runs and does not install anything. The Basecoat bonus chapter also says to copy the showcase from vendor/ after add, not from the raw GitHub tree (#3378)
  • The application template's onApplicationEnd() handler now invokes the Wheels global through the passed-in arguments.applicationScope.wo (guarded with StructKeyExists) instead of the live application.wo scope. On Adobe ColdFusion 2023 the application scope is unreliable during applicationStop() teardown, so bare application.wo could resolve to a stale Java String[] and throw Element wo is undefined in a Java object of type class [Ljava.lang.String;, erroring the whole site until a CF service restart. The same fix is applied to the repo's demo app and the bundled example apps; existing apps should apply the same edit to their public/Application.cfc (#3379)
  • Debug bar reload link (and the CFML error page's displayed URL) now honors the subpath
    setting: the base URL is composed from the resolved webPath plus the front-controller
    filename — the same idiom as urlFor() — instead of raw cgi.script_name, so subfolder
    deployments emit /myapp/posts?reload= instead of the unroutable
    /myapp/public/index.cfm/posts?reload=. Root installs render byte-identical to before.
    Extracted into the unit-tested $buildDebugReloadUrl() helper in Global.cfc (#3344)
  • Debug bar: the minimized "Debug" restore button now renders after clicking the X. The #wdb-minimized button was nested inside the #wheels-debugbar container that wdbMinimize() hides with display:none, so it could never appear and the bar stayed gone for the whole browser session (manual sessionStorage cleanup was the only recovery). It is now a sibling of the container, so minimizing shows the restore button bottom-right and clicking it brings the bar back (#3345).
  • Repointed 16 dead v4-0-0-snapshot- and 3.1.0-era guide URLs at live guides.wheels.dev/v4-0-0/ pages: the scaffolded app's config/settings.cfm/routes.cfm/environment.cfm comments, three template READMEs (mailers/jobs/plugins), both ConfigRoutes.txt templates, two runtime CLI messages (Module.cfc install pointer, Doctor.cfc remediation), cli/README.md, the analyze report footer, and the demo app's config. ConfigRoutesStaleDocUrlSpec now structurally guards the template tree and known runtime-message files against reintroducing retired URL shapes.
  • The test framework (wheels.wheelstest) no longer crashes when an exception object lacks the optional cfcatch members stackTrace / extendedInfo — e.g. custom-thrown, deserialized, or engine-variant exceptions. Spec-result recording (BaseSpec fail/error catch blocks), Assertion.throws(), the bundle-runner rethrow and status-header logging in TestBox.cfc, and the JUnit reporter's failure/error/global-exception sections now default missing members to an empty string instead of nuking the whole bundle's results. A structural spec (ReporterCfcatchGuardSpec) pins every optional-member read under wheelstest/system to the null-safe idiom
  • Engine detection now recognizes RustCFML v0.507+ running in its default reportAsLucee mode (where server.coldfusion.productName reports "Lucee") by matching the stable server.lucee.versionName == "RustCFML" identity marker, so the dedicated RustCFML engine adapter is selected instead of the Lucee adapter and adapter-gated behavior (image-info support, capability probes, engine version gates) routes correctly again.
  • Web test runner isolation: /wheels/core/tests and /wheels/app/tests (and
    TestClient / browser requests that send X-Wheels-Test-Context or the
    WHEELS_TEST_CONTEXT cookie) now bind a separate CFML application name
    (<this.name>_wheelsTest) when Application.cfc includes
    vendor/wheels/events/testcontext.cfm after config/app.cfm. The live
    application.wheels is no longer swapped for the duration of a run, so
    concurrent normal requests keep production config. The snippet ships in
    wheels new and the demo app; existing apps keep the #3373
    named-lock swap on the live scope until they add the include
    (refs #3374).
  • Web test runner (/wheels/core/tests and /wheels/app/tests): the swap→run→restore window that
    temporarily replaces the live application.wheels config with test configuration is now serialized
    under an exclusive named lock, and the restore runs in a finally block. Overlapping test requests
    can no longer clobber each other's application.$$$wheels backup and leave test config live until
    the next reload=true, and an erroring suite now restores the original config too. ParallelRunner
    partition sub-requests detect the already-applied swap and skip both the swap and the shared lock,
    so parallel test mode does not deadlock. Note: this serializes test-vs-test only — a normal request
    concurrent with a test run still sees swapped config; true isolation is deferred to a
    separate-application-context design (refs #3025).
    Also removes the orphaned legacy RocketUnit runner twin vendor/wheels/rocketunit_tests/Test.cfc
    (nothing loads it; the active legacy chain via wheels.Test is unchanged).

Security

  • The /wheels welcome page now defense-in-depth gates itself with $blockInProduction() like every other Public handler, so it no longer renders outside development when enablePublicComponent is manually enabled — closing a version/engine/database/environment disclosure gap (reverses the #2233 exception)