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
2 changes: 1 addition & 1 deletion .ai/wheels/cross-engine-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ createDynamicProxy(consumer, ["java.util.function.Consumer"]);

### `for` Loops Inside `finally` Blocks Miscompile on Lucee 7

Lucee 7.0.1+100 throws `variable [local] doesn't exist` at runtime when a `for` loop declares or iterates `local`-/`var`-scoped variables inside a `finally` block. Isolated with minimal probes: bare assignments and function calls inside `finally` compile and run fine; loops do not. One probe shape even produced a JVM `Expecting a stackmap frame` bytecode-verifier error, pointing at a codegen bug in Lucee's `finally`-block compilation.
Lucee 7.0.1+100 throws `variable [local] doesn't exist` at runtime when a `for` loop declares or iterates `local`-/`var`-scoped variables inside a `finally` block. Both loop forms are affected — `for (init; cond; step)` and `for (item in collection)`. Isolated with minimal probes: bare assignments and function calls inside `finally` compile and run fine; loops do not. One probe shape even produced a JVM `Expecting a stackmap frame` bytecode-verifier error, pointing at a codegen bug in Lucee's `finally`-block compilation.

```cfm
// WRONG — crashes at runtime on Lucee 7
Expand Down
1 change: 1 addition & 0 deletions changelog.d/reviewer-nit-sweep.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Reviewer-nit sweep from the 2026-06 remediation campaign (#2977). Behavior fixes: conditional validations with uppercase word-form operators (`condition="1 EQ 0"`) no longer throw on Adobe CF (`$evaluateLogicalExpression` now lowercases the operator); the `/wheels/cli` mutation gate reads the reload password from the form scope ONLY, so a `?password=...` query string can no longer satisfy the gate while logging the password in access logs; `dbRollback` over the `/wheels/cli` bridge counts applied migrations by tracked `status` instead of the `version <= current` heuristic, so peer-applied versions on a shared dev database no longer skew `steps=N`; the CLI's three HTTP bridge helpers guard against `getErrorStream()` returning Java null on bodiless 4xx/5xx responses (was an NPE surfacing as a useless "null" error); the migrator's per-request column cache keys on the verbatim table name (case-folding let `Authors`/`authors` share a slot on case-sensitive databases); `$getForeignKeys()` throws `Wheels.Migrator.MissingAdapter` instead of silently interpolating an unquoted table name when the adapter is missing; and the dead ISO-date fallback branch in `$convertToString` uses real `\d` regex escapes. Plus assorted stale-docblock/comment updates (#2903 references, `renderWith`/`onlyProvides` enforcement notes, debug-panel guide note) and spec backfills (`waitForText` timeout surface, `$get()` without `request.wheels`, typed-column outlier defaults, conditional spec-state restore) (#2977)
18 changes: 18 additions & 0 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -6340,6 +6340,12 @@ component extends="modules.BaseModule" {

var responseCode = conn.getResponseCode();
var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream();
// getErrorStream() returns Java null on a bodiless 4xx/5xx response;
// Scanner.init(null) NPEs on Lucee and surfaces as a useless "null"
// error message (#2947 review, #2977). No body — return empty.
if (isNull(inputStream)) {
return "";
}
var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8");
var response = "";
while (scanner.hasNextLine()) {
Expand Down Expand Up @@ -6373,6 +6379,12 @@ component extends="modules.BaseModule" {

var responseCode = conn.getResponseCode();
var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream();
// getErrorStream() returns Java null on a bodiless 4xx/5xx response;
// Scanner.init(null) NPEs on Lucee and surfaces as a useless "null"
// error message (#2947 review, #2977). No body — return empty.
if (isNull(inputStream)) {
return "";
}
var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8");
var response = "";
while (scanner.hasNextLine()) {
Expand Down Expand Up @@ -6403,6 +6415,12 @@ component extends="modules.BaseModule" {
// Read response (handle both success and error streams)
var responseCode = conn.getResponseCode();
var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream();
// getErrorStream() returns Java null on a bodiless 4xx/5xx response;
// Scanner.init(null) NPEs on Lucee and surfaces as a useless "null"
// error message (#2947 review, #2977). No body — return empty.
if (isNull(inputStream)) {
return "";
}
var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8");
var response = "";
while (scanner.hasNextLine()) {
Expand Down
10 changes: 7 additions & 3 deletions vendor/wheels/Global.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -2880,9 +2880,13 @@ return local.$wheels;
// fallback parsing attempts for common formats

// 1) ISO YYYY-MM-DD[ hh[:mm[:ss]]]
if (ReFind("(?i)^(\\d{4})-(\\d{2})-(\\d{2})(?:[ T](\\d{1,2}):(\\d{2})(?::(\\d{2}))?)?$", local.s2)) {
local.parts = ReReplace(local.s2, "^(\\d{4})-(\\d{2})-(\\d{2}).*$", "\\1-\\2-\\3", "all");
local.timePart = ReReplace(local.s2, ".*[ T](\\d{1,2}:\\d{2}(?::\\d{2})?).*$", "\\1", "all");
// Single-backslash escapes: in CFML "\\d" is a literal
// backslash + d in the compiled regex, which never matches a
// digit — the branch was dead. Mirrors the already-fixed
// slash-format branch below (#2933 carry-forward, #2977).
if (ReFind("(?i)^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$", local.s2)) {
local.parts = ReReplace(local.s2, "^(\d{4})-(\d{2})-(\d{2}).*$", "\1-\2-\3", "all");
local.timePart = ReReplace(local.s2, ".*[ T](\d{1,2}:\d{2}(?::\d{2})?).*$", "\1", "all");
if (Len(local.timePart) AND local.timePart NEQ local.s2) {
// has time
local.dt = ParseDateTime(local.parts & " " & local.timePart);
Expand Down
5 changes: 3 additions & 2 deletions vendor/wheels/Public.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,9 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" {

/**
* Returns a struct { packages: [...], error: "" } populated from the
* wheels-packages registry. Short-circuits in production (defense in
* depth — the handler is already $blockInProduction()-gated). Captures
* wheels-packages registry. Short-circuits outside development (defense in
* depth — the handler is already $blockInProduction()-gated, which since
* #2903 is a development-only allowlist). Captures
* any registry error into the `error` field so the view can render a
* friendly banner instead of a stack trace.
*
Expand Down
3 changes: 3 additions & 0 deletions vendor/wheels/controller/provides.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ component {
/**
* Use this in an individual controller action to define which formats the action will respond with.
* This can be used to define provides behavior in individual actions or to override a global setting set with `provides` in the controller's `config()`.
* Restrictions are enforced (since 4.0.4): `renderWith()` falls back to the `html` view for a
* format outside the list, and the automatic render in `$callAction()` skips view rendering for
* non-acceptable, non-html formats.
*
* [section: Controller]
* [category: Provides Functions]
Expand Down
3 changes: 3 additions & 0 deletions vendor/wheels/controller/rendering.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ component {
* Instructs the controller to render the data passed in to the format that is requested.
* If the format requested is `json` or `xml`, Wheels will transform the data into that format automatically.
* For other formats (or to override the automatic formatting), you can also create a view template in this format: `nameofaction.xml.cfm`, `nameofaction.json.cfm`, `nameofaction.pdf.cfm`, etc.
* Per-action format restrictions set with `onlyProvides()` are enforced here (since 4.0.4):
* when the requested format is not acceptable for the action, `renderWith()` falls back to
* rendering the `html` view — even when `html` itself is not in the `onlyProvides()` list.
*
* [section: Controller]
* [category: Rendering Functions]
Expand Down
16 changes: 14 additions & 2 deletions vendor/wheels/migrator/Base.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,16 @@ component extends="wheels.Global"{
// rather than assigning a bare local inside catch: BoxLang discards
// `local.X = ...` assignments made in a catch body.)
local.state = {tableExists = true};
local.quotedTable = StructKeyExists(this, "adapter") ? this.adapter.quoteTableName(arguments.table) : arguments.table;
// Migration.init() always sets this.adapter, so a missing adapter is a
// broken instantiation — fail loudly rather than silently interpolating
// an UNQUOTED table name into SQL (#2937 review, #2977).
if (!StructKeyExists(this, "adapter")) {
Throw(
type = "Wheels.Migrator.MissingAdapter",
message = "$getForeignKeys() requires an initialized database adapter. Instantiate migrations through Migration.init()."
);
}
local.quotedTable = this.adapter.quoteTableName(arguments.table);
try {
$query(
datasource = application[local.appKey].dataSourceName,
Expand Down Expand Up @@ -206,7 +215,10 @@ component extends="wheels.Global"{
// would otherwise issue a full table-metadata round-trip per row.
// $execute() drops the cache whenever a statement runs, so DDL in the
// same request (addColumn() etc.) is reflected on the next read.
local.cacheKey = LCase(application[local.appKey].dataSourceName & "|" & arguments.tableName);
// Key on the VERBATIM table name: the $dbinfo probe below uses original
// case, so case-folding the key would let `Authors` and `authors` share
// one slot on case-sensitive databases (#2937 review, #2977).
local.cacheKey = application[local.appKey].dataSourceName & "|" & arguments.tableName;
if (
StructKeyExists(request, "$wheelsMigratorColumns")
&& StructKeyExists(request.$wheelsMigratorColumns, local.cacheKey)
Expand Down
7 changes: 7 additions & 0 deletions vendor/wheels/migrator/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ The flag is read via `$get("useUnderscoreReferenceColumns")` inside `references(
2. **Hard-coding `& "id"` or `& "type"` concatenations.** All four sites in this directory resolve the reference-column suffix through `$get("useUnderscoreReferenceColumns")` — `TableDefinition.cfc::references()` (id + polymorphic type), `Migration.cfc::removeColumn` (referenceName branch), and `Migration.cfc::addReference`. If you add new code that builds a reference column name, route it through `$get` too rather than hard-coding `& "id"`.
3. **`required` on column-name parameters.** Use `$combineArguments(... required=true)` instead. Declaring CFML-level `required` blocks the alias path because validation runs before the function body.

## Internal caches

Two caches introduced in #2937 — know their scopes before adding probes:

- `application[appKey].$migratorAdapterNames` — application-scoped, keyed by datasource name. Memoized migrator adapter name, written by `Base.cfc::$getDBType()`. Survives requests; rebuilt on reload (a datasource's driver can't change without one).
- `request.$wheelsMigratorColumns` — request-scoped, keyed by `dsName|tableName` (table name VERBATIM — no case folding, since the `$dbinfo` probe uses original case and case-sensitive databases can host `Authors` and `authors` separately). Column list per table, written by `Base.cfc::$getColumns()`, dropped wholesale by `$execute()` so DDL in the same request is reflected on the next read.

## Tests

Specs live in `vendor/wheels/tests/specs/migrator/`. `referencesSpec.cfc` exercises `TableDefinition::references()` (the `columnNames` alias plus the suffix flag) at the unit layer — inspecting `t.columns` / `t.foreignKeys` directly without `t.create()` so the assertions are adapter-independent. `primaryKeySpec.cfc` mirrors that shape for `TableDefinition::primaryKey()` — the `columnName` / `columnNames` aliases plus precedence semantics (#2803). `migrationSpec.cfc` covers Migration.cfc command-version helpers via real DDL roundtrips — its "Tests addReference" describe block guards the `useUnderscoreReferenceColumns` path on `Migration.cfc::addReference()`. Most FK-related tests in `migrationSpec.cfc` skip on SQLite (which doesn't support altering CONSTRAINTS) but run on every other engine in CI.
Expand Down
6 changes: 5 additions & 1 deletion vendor/wheels/model/validations.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,11 @@ component {
}
local.leftOperand = IsNumeric(local.tokens[1]) ? JavaCast("double", local.tokens[1]) : local.tokens[1];
local.rightOperand = IsNumeric(local.tokens[3]) ? JavaCast("double", local.tokens[3]) : local.tokens[3];
return $resolveOperator(local.leftOperand, local.rightOperand, local.tokens[2]);
// LCase keeps word-form operators ("1 EQ 0") compatible with the
// case-sensitive switch in $resolveOperator on Adobe CF — symbolic
// operators are already lowercased by $normalizeConditionOperators,
// but word-form ones arrive raw (#2977).
return $resolveOperator(local.leftOperand, local.rightOperand, LCase(local.tokens[2]));
}

/**
Expand Down
13 changes: 11 additions & 2 deletions vendor/wheels/public/views/cli.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ try {
requestMethod = cgi.request_method,
remoteAddr = cgi.remote_addr,
forwardedFor = cgi.http_x_forwarded_for,
password = StructKeyExists(request.wheels.params, "password") ? request.wheels.params.password : ""
// Form scope ONLY: request.wheels.params merges URL + form, so a
// ?password=... query string would satisfy the gate while logging
// the reload password in access logs / proxies — contradicting the
// SEC-4 design of carrying it as a form field (#2947 review, #2977).
password = StructKeyExists(form, "password") ? form.password : ""
);
if (!local.gate.allowed) {
cfheader(statuscode = local.gate.statusCode);
Expand Down Expand Up @@ -318,9 +322,14 @@ try {

// Find target version based on steps. Reuses the list
// discovered in the preamble instead of re-discovering.
// Filter on tracked status, not version <= current: on a shared
// dev DB a peer-applied version above your latest local file
// made the version heuristic count pending/orphan rows as
// applied, so `steps=N` rolled back fewer real migrations
// (same P3 fix dbStatus got in #2947; #2977).
local.appliedMigrations = [];
for (local.migration in data.migrations) {
if (local.migration.version <= data.currentVersion) {
if (local.migration.status == "migrated") {
arrayAppend(local.appliedMigrations, local.migration);
}
}
Expand Down
11 changes: 7 additions & 4 deletions vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ component extends="wheels.WheelsTest" {
it("invokes a Public.cfc instance without throwing on $blockInProduction", function() {
// End-to-end shape of the dispatch flow at Dispatch.cfc:287.
// We don't actually serve a request — we just verify the
// adapter can invoke a Public.cfc handler. In non-production
// environments $blockInProduction() short-circuits to a no-op,
// so the only thing we're testing is "did the receiver survive
// adapter can invoke a Public.cfc handler. In the development
// environment $blockInProduction() short-circuits to a no-op
// (since #2903 the gate is a development-only allowlist), so
// the only thing we're testing is "did the receiver survive
// the dispatch?" If it didn't, the call throws before the
// include statement runs.
// include statement runs. (This spec invokes the ungated
// index() handler, so the production-only early-return below
// is belt-and-suspenders.)
if (
StructKeyExists(application, "wheels")
&& StructKeyExists(application.wheels, "environment")
Expand Down
33 changes: 33 additions & 0 deletions vendor/wheels/tests/specs/global/getSettingRequestScopeSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Regression surface of the DC16 fix (#2933): $get()'s per-tenant override
* lookup traverses request.wheels.tenant.config via a StructKeyExists chain.
* The exact hazard the IsDefined→StructKeyExists rewrite guarded against is
* an ABSENT request.wheels (early bootstrap, CLI call sites) — the happy
* tenant-override paths are covered by MultiTenantIntegrationSpec; this
* pins the no-throw contract for the absent case (#2977).
*/
component extends="wheels.WheelsTest" {

function run() {

describe("$get() without request.wheels", () => {

it("does not throw when request.wheels is absent", () => {
var had = StructKeyExists(request, "wheels");
var saved = had ? request.wheels : {};
StructDelete(request, "wheels");
try {
var value = application.wo.$get("environment");
expect(value).toBe(application.wheels.environment);
} finally {
if (had) {
request.wheels = saved;
}
}
});

});

}

}
21 changes: 17 additions & 4 deletions vendor/wheels/tests/specs/global/loadRoutesSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,27 @@ component extends="wheels.WheelsTest" {

function beforeAll() {
_originalRoutes = Duplicate(application.wheels.routes)
_originalStaticRoutes = StructKeyExists(application.wheels, "staticRoutes") ? StructCopy(application.wheels.staticRoutes) : {}
_originalNamedRoutePositions = StructKeyExists(application.wheels, "namedRoutePositions") ? StructCopy(application.wheels.namedRoutePositions) : {}
_hadStaticRoutes = StructKeyExists(application.wheels, "staticRoutes")
_originalStaticRoutes = _hadStaticRoutes ? StructCopy(application.wheels.staticRoutes) : {}
_hadNamedRoutePositions = StructKeyExists(application.wheels, "namedRoutePositions")
_originalNamedRoutePositions = _hadNamedRoutePositions ? StructCopy(application.wheels.namedRoutePositions) : {}
}

function afterAll() {
application.wheels.routes = _originalRoutes
application.wheels.staticRoutes = _originalStaticRoutes
application.wheels.namedRoutePositions = _originalNamedRoutePositions
// Restore only what existed: an unconditional assignment would leave a
// spurious empty key behind when the spec ran before the app ever
// populated these caches (#2933 review, #2977).
if (_hadStaticRoutes) {
application.wheels.staticRoutes = _originalStaticRoutes
} else {
StructDelete(application.wheels, "staticRoutes")
}
if (_hadNamedRoutePositions) {
application.wheels.namedRoutePositions = _originalNamedRoutePositions
} else {
StructDelete(application.wheels, "namedRoutePositions")
}
}

function run() {
Expand Down
45 changes: 45 additions & 0 deletions vendor/wheels/tests/specs/migrator/typedColumnDefaultsSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Spot-check that the per-type outlier parameter defaults survived the
* $addTypedColumns helper-dedup refactor (#2937 review, #2977).
*
* Most typed column helpers declare `string default` / `boolean allowNull`
* with NO default value; `float()` is the long-standing outlier with
* `default=""` / `allowNull=true` (preserved for backward compatibility —
* addColumnOptions renders default="" as DEFAULT NULL). A future cleanup
* that "harmonizes" the signatures would silently change emitted DDL; this
* spec pins the divergence on the built column definition itself.
*/
component extends="wheels.WheelsTest" {

function beforeAll() {
variables.migration = CreateObject("component", "wheels.migrator.Migration").init();
}

function run() {

describe("TableDefinition typed-column outlier defaults", () => {

it("float() applies its default='' / allowNull=true outlier defaults", () => {
var t = variables.migration.createTable(name = "dbm_typed_defaults_test", force = true);
t.float(columnNames = "ratio");

expect(ArrayLen(t.columns)).toBe(1);
expect(t.columns[1].type).toBe("float");
expect(t.columns[1]).toHaveKey("default");
expect(t.columns[1]["default"]).toBe("");
expect(t.columns[1].allowNull).toBeTrue();
});

it("integer() does not inherit float()'s outlier defaults", () => {
var t = variables.migration.createTable(name = "dbm_typed_defaults_test2", force = true);
t.integer(columnNames = "age");

expect(ArrayLen(t.columns)).toBe(1);
expect(t.columns[1].type).toBe("integer");
});

});

}

}
16 changes: 16 additions & 0 deletions vendor/wheels/tests/specs/model/validationsSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,22 @@ component extends="wheels.WheelsTest" {
assert_test(user, true)
})

it("if validation using uppercase word-form operator", () => {
// Word-form operators arrive raw in $evaluateLogicalExpression
// (only symbolic operators are pre-lowercased) — uppercase EQ
// used to hit the case-sensitive switch in $resolveOperator on
// Adobe CF and throw (##2977).
args.condition = "1 EQ 1"
user.validatesLengthOf(argumentCollection = args)
assert_test(user, false)
})

it("unless validation using uppercase word-form operator", () => {
args.unless = "1 EQ 1"
user.validatesLengthOf(argumentCollection = args)
assert_test(user, true)
})

it("if validation using method invalid", () => {
args.condition = "isnew()"
user.validatesLengthOf(argumentCollection = args)
Expand Down
Loading
Loading