Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
144 commits
Select commit Hold shift + click to select a range
dc49c55
chore: bump develop snapshot target to 4.0.6 (#3229)
github-actions[bot] Jun 19, 2026
5ac5e8a
ci: gate yum + apt-arm64 install-smoke (hard-fail) now that 4.0.5 fix…
bpamiri Jun 19, 2026
eb73cb6
docs(web/blog): announce Wheels 4.0.5 (with 4.0.4 hardening) (#3231)
bpamiri Jun 19, 2026
901e6dd
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jun 19, 2026
25d1cd5
build(deps): Bump form-data in /tools/docs-validation (#3224)
dependabot[bot] Jun 19, 2026
61c5c99
build(deps-dev): Bump js-yaml in /tools/docker/testui (#3225)
dependabot[bot] Jun 19, 2026
61c594e
ci: add index-integrity probe to distribution install smoke (#3218 re…
bpamiri Jun 19, 2026
d0c343a
docs(web/blog): use local.di (not top-level var) in from-wirebox-to-w…
bpamiri Jun 20, 2026
8f73b77
perf(model): cache mixin-integration plan to fix per-instance overhea…
bpamiri Jun 20, 2026
53e6778
docs: add Wheels 5 roadmap recommendations
bpamiri Jun 20, 2026
0d25337
docs: consolidate Wheels 5 roadmap into a single decided document (#3…
bpamiri Jun 20, 2026
a748630
fix(model): evaluate positional args in this.method() validation cond…
bpamiri Jun 22, 2026
8cfd176
docs(web/blog): Beyond findAll: Scopes, Enums, and the Chainable Quer…
Jun 22, 2026
db98af0
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jun 22, 2026
944f458
refactor(public): extract cli.cfm command handlers into CliBridge ser…
bpamiri Jun 22, 2026
181cb6d
fix: anchor app-template rootPath to GetCurrentTemplatePath for subfo…
bpamiri Jun 22, 2026
7e65f61
docs(web/blog): Real-Time Without WebSockets: Server-Sent Events in W…
Jun 23, 2026
fe5fc42
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jun 23, 2026
5056ea4
docs(web/guides): document buttonTo input-prefix convention and schem…
bpamiri Jun 24, 2026
121b64d
docs(web/guides): name the dead cfwheels-base-template slug and redir…
bpamiri Jun 24, 2026
bab07fd
chore: add reusable triage-discussions workflow (#3256)
bpamiri Jun 24, 2026
f902f24
docs(web/blog): Pagination That Isn't a Pain: paginationNav and the P…
Jun 24, 2026
ee3ffe0
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jun 24, 2026
d2bb78a
feat(storage): add storage-disk abstraction with local and S3 drivers…
bpamiri Jun 24, 2026
4ac94b7
fix(model): emit flat joins for belongsTo-chain nested includes (#324…
bpamiri Jun 24, 2026
c7ca961
feat(model): add includeCalculated to additively opt in select=false …
wheels-bot[bot] Jun 24, 2026
f2bb8de
fix(test): resolve app test-runner include under URL subpath installs…
wheels-bot[bot] Jun 24, 2026
2a2ca9c
fix(test): render the app test-runner HTML report (#3251) (#3258)
bpamiri Jun 24, 2026
c53d5e8
docs(web/blog): Writing Your Own Middleware in Wheels 4.0
Jun 25, 2026
c252a49
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jun 25, 2026
21719ec
feat: scaffolded home page onboarding + /wheels welcome production ga…
bpamiri Jun 26, 2026
968074c
docs(web/blog): Dependency Injection in Wheels 4.0: services.cfm, Sco…
Jun 26, 2026
07c907d
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jun 26, 2026
e09f8ac
docs(web/blog): Seeding Your Database the Idempotent Way
Jun 29, 2026
c0a294e
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jun 29, 2026
cd6fb56
docs(web/blog): Routing Deep-Dive: Resources, Nested Callbacks, and R…
Jun 30, 2026
916cd1d
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jun 30, 2026
a05e0a0
docs(web/blog): Validations Beyond Presence: Conditional Rules, Custo…
Jul 1, 2026
e7999cb
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 1, 2026
9361c81
docs(web/blog): Form Helpers in Wheels 4.0: Object Forms, HTML5 Field…
Jul 2, 2026
e91e3ed
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 2, 2026
b4c5e38
docs(web/blog): Associations Deep-Dive: hasMany, belongsTo, Nested Pr…
Jul 3, 2026
8643b70
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 3, 2026
d9d58bf
docs(web/guides): rescue orphaned pages, regroup sidebar, and fix 404…
bpamiri Jul 4, 2026
f289f4c
docs(web/guides): repair wrong-API and broken code samples across 20 …
bpamiri Jul 5, 2026
c9255a5
docs(web/guides): add the no-CLI path and five-journey front door (re…
bpamiri Jul 5, 2026
01f7a10
docs(web/guides): give every tooling-gated task page a without-the-CL…
bpamiri Jul 5, 2026
1a3f22c
docs(web/guides): add What's New in 4.0 and URL Rewriting, fix vm-dep…
bpamiri Jul 5, 2026
334a719
docs(web/guides): single-source Kamal content, strip author residue, …
bpamiri Jul 5, 2026
1a1241c
fix(cli): repoint 15 dead v4-0-0-snapshot and 3.1.0 guide URLs at liv…
bpamiri Jul 6, 2026
3545262
chore(web): refresh guides visual baseline for the restructured tutor…
bpamiri Jul 6, 2026
f197e1a
build(deps): Bump astro from 6.3.1 to 6.4.6 in /web (#3226)
dependabot[bot] Jul 6, 2026
eb183a8
fix(web): make lockup logo color-scheme aware so it stays visible in …
bpamiri Jul 6, 2026
194a669
ci: route refreshed visual baselines through a PR instead of a direct…
bpamiri Jul 6, 2026
2af35e1
docs(web/blog): Caching in Wheels 4.0: Actions, Queries, Partials, an…
Jul 6, 2026
fdeaedc
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 6, 2026
d871a97
feat(auth): PBKDF2 password hashing service (PasswordHasher) (#3288)
bpamiri Jul 6, 2026
5509082
feat(controller): authorization policy layer — wheels.Policy + author…
bpamiri Jul 6, 2026
bb6bc41
feat(cli): wheels generate auth — session/token/jwt scaffold on the a…
bpamiri Jul 6, 2026
59fb034
fix(build): retire dead 3.1.0 guide URL in docker engine settings tem…
bpamiri Jul 6, 2026
a2e9101
fix(test): null-safe optional cfcatch members in test reporter (#3295)
bpamiri Jul 6, 2026
4d5fe51
test(view): assert includePartial guard via error message instead of …
bpamiri Jul 6, 2026
b767677
feat: probe-based engine capabilities with RustCFML detected before i…
bpamiri Jul 6, 2026
e7a7384
perf(channel): bound cleanup candidate query in SQL instead of driver…
bpamiri Jul 6, 2026
b082f4a
ci: add experimental RustCFML soft-fail lane to the compat matrix (#3…
bpamiri Jul 6, 2026
47fdb0a
fix(auth): route PBKDF2 getEncoded() through the SecretKey interface …
bpamiri Jul 7, 2026
e6db19f
docs(web/blog): File Uploads in Wheels 4.0, the Idiomatic Way
Jul 7, 2026
953a11b
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 7, 2026
ec23620
docs(web/blog): Sending Email in Wheels 4.0: sendEmail, Templates, an…
Jul 8, 2026
c505042
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 8, 2026
988d877
ci: bump RustCFML lane pin to v0.429.0 and refresh baseline (#3305)
bpamiri Jul 8, 2026
04b3476
docs(web/blog): Wheels 4 Without the Tooling: Two Zips, Zero Dependen…
Jul 9, 2026
f873452
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 9, 2026
3275291
docs(web/blog): One Authenticator, Three Doors: Sessions, Tokens, and…
Jul 10, 2026
d40f978
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 10, 2026
f6d4177
docs(web/blog): CORS in Wheels 4: Deny by Default, Open It Correctly
Jul 14, 2026
1b99b30
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 14, 2026
76185cc
docs(web/blog): Request IDs, /up, and Logs a Machine Can Read: Observ…
Jul 14, 2026
406d274
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 14, 2026
173e9ea
docs(web/blog): How Wheels Actually Reads Your Configuration
Jul 15, 2026
5e0771b
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 15, 2026
45d313f
docs(web/blog): Running Wheels 4 the CommandBox Way
Jul 16, 2026
c013ead
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 16, 2026
1db4b52
docs(web/blog): Processing 400,000 Rows Without Melting the JVM: find…
Jul 17, 2026
faf0f1d
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 17, 2026
a4b5143
docs(web/blog): Channels: Pub/Sub for Wheels 4, No Extra Infrastructure
Jul 20, 2026
78653fa
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 20, 2026
8b57f96
docs(web/blog): wheels console: The Debugging Move You're Not Using
Jul 21, 2026
5ae2679
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 21, 2026
7a9cad7
docs(web/blog): Calculated Properties: SQL-Defined Values Without the…
Jul 22, 2026
cb4b6d7
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 22, 2026
f493e65
docs(web/blog): The Debug Bar: A Complete Tour of Wheels 4's Developm…
Jul 23, 2026
62b0637
docs(web/blog): Internationalization in an Afternoon: wheels-i18n
Jul 24, 2026
35a1956
chore(docs): auto-refresh blog visual baseline after content change […
wheels-bot[bot] Jul 24, 2026
aa7f01b
feat(events): dev-mode debug-bar notice when ?reload=true is refused …
bpamiri Jul 28, 2026
7789d77
build(deps-dev): Bump js-yaml from 4.1.1 to 4.3.0 (#3315)
dependabot[bot] Jul 28, 2026
133f38e
build(deps-dev): Bump ws from 8.18.3 to 8.21.1 in /tools/docker/testu…
dependabot[bot] Jul 28, 2026
be4ab5a
build(deps): Bump @astrojs/rss from 4.0.18 to 4.0.19 in /web (#3320)
dependabot[bot] Jul 28, 2026
9a4229a
fix(events): detect RustCFML under reportAsLucee impersonation (v0.50…
bpamiri Jul 28, 2026
f7d81bd
build(deps): Bump postcss from 8.5.15 to 8.5.24 in /tools/docker/test…
dependabot[bot] Jul 28, 2026
46f534b
build(deps-dev): Bump fast-uri from 3.1.0 to 3.1.4 (#3329)
dependabot[bot] Jul 28, 2026
105c4b8
build(deps): Bump brace-expansion and vue-tsc in /tools/docker/testui…
dependabot[bot] Jul 28, 2026
c35d88a
build(deps-dev): Bump js-yaml in /tools/docker/testui (#3331)
dependabot[bot] Jul 28, 2026
5793357
build(deps): Bump sharp from 0.33.5 to 0.35.0 in /web (#3330)
dependabot[bot] Jul 28, 2026
fc59492
build(deps): Bump astro from 6.4.6 to 7.1.1 in /web (#3321)
dependabot[bot] Jul 28, 2026
af69eb5
fix(model): namespace per-request query cache under request.wheels.$q…
bpamiri Aug 3, 2026
ce2d5b6
fix(controller): namespace pagination handles under request.wheels.$p…
bpamiri Aug 3, 2026
17a4833
build(deps-dev): Bump ip-address in /tools/docker/testui
dependabot[bot] Aug 3, 2026
ca835c3
test(model): assert updateAll multi-include by row identity, not floa…
bpamiri Aug 4, 2026
ab901cf
fix(model): resolve association foreign key defaults against either r…
bpamiri Aug 4, 2026
4db616f
fix(model): guard the scope dereference in validatesUniquenessOf (#3355)
bpamiri Aug 4, 2026
1987187
fix(cli): give wheels test its own timeout budget instead of the brid…
bpamiri Aug 4, 2026
80773ab
fix(controller): register super<name> for app-level controller and vi…
bpamiri Aug 4, 2026
e8d094f
fix(model): exclude system-schema columns from PostgreSQL table intro…
bpamiri Aug 4, 2026
dd77ff5
fix(job): name the queue row when a persisted jobClass cannot be reso…
bpamiri Aug 4, 2026
2c98644
fix(model): scope a nested include's inner join to its own parent joi…
bpamiri Aug 4, 2026
a3927d2
fix(security): validate the CSRF decrypt result instead of trusting t…
bpamiri Aug 4, 2026
31a59e8
chore: ignore the runtime artifacts a local test run writes into the …
bpamiri Aug 4, 2026
9cbbda6
fix(model): read a join's type from association metadata, not from th…
bpamiri Aug 4, 2026
8bc8304
chore: remove the TestBox stubs that #3360 re-added after #3363 remov…
bpamiri Aug 4, 2026
c5f4558
ci: compat-matrix safe-slice hardening — zero-test guard, neutral sof…
bpamiri Aug 5, 2026
8bfa351
docs(web/guides): document wheels-websockets package; correct legacy …
bpamiri Aug 5, 2026
f344e7d
feat(model): allow select() and friends to start a query-builder chai…
bpamiri Aug 5, 2026
1da1f20
docs(web/guides): performance notes for 2.x upgraders (#3369)
bpamiri Aug 5, 2026
c8d24f0
docs(web/guides): add v4 Overriding Core Methods guide (#3343) (#3372)
bpamiri Aug 5, 2026
9d28432
fix(test): serialize web test-runner config swap under a named lock (…
bpamiri Aug 5, 2026
9e8a8b6
fix(events): render debug bar restore button outside the container it…
bpamiri Aug 5, 2026
3b7199c
fix(events): make debug-bar reload link subpath-aware (#3344) (#3371)
bpamiri Aug 5, 2026
11952f3
test(model): use non-aggregate terminals in forUpdate chain specs (#3…
bpamiri Aug 5, 2026
6bff054
fix(compat): burn down compat-matrix engine-leg debt root causes
bpamiri Aug 5, 2026
a9d2ff4
ci: make compat-matrix engine-job failures fail the run (#3376)
bpamiri Aug 5, 2026
17872ef
build(deps-dev): Bump js-yaml from 4.3.0 to 4.3.1
dependabot[bot] Aug 12, 2026
af58532
fix(config): route onApplicationEnd through applicationScope.wo to su…
github-actions[bot] Aug 18, 2026
e9699f4
docs: document onApplicationEnd application scope teardown hazard on …
github-actions[bot] Aug 18, 2026
e1f8ba9
test(cli): cover every shipped onApplicationEnd handler in the Adobe …
cursoragent Aug 20, 2026
2d2f9a5
test(cli): cover every shipped onApplicationEnd handler for #3379
bpamiri Aug 20, 2026
adbe815
build(deps-dev): Bump js-yaml from 4.3.0 to 4.3.1
bpamiri Aug 20, 2026
ec16e93
Merge branch 'develop' into dependabot/npm_and_yarn/tools/docker/test…
bpamiri Aug 20, 2026
7bef4ed
build(deps-dev): Bump ip-address from 10.2.0 to 10.4.0 in /tools/dock…
bpamiri Aug 20, 2026
ae263f9
fix: advertise wheels packages add (not install) on docs and help
bpamiri Aug 20, 2026
cc14b48
fix(test): isolate the web runner in a separate application scope (#3…
bpamiri Aug 20, 2026
638c0f5
refactor(global): decompose Global.cfc into focused includes (#3241)
bpamiri Aug 20, 2026
5a6c61a
ci: bump LuCLI pin to 0.6.1
bpamiri Aug 20, 2026
51c2c53
chore(release): assemble changelog.d fragments into [4.0.6] (#3386)
bpamiri Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
68 changes: 68 additions & 0 deletions .ai/wheels/cross-engine-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,38 @@ plugin.onPluginLoad(context);

**Why**: Adobe's application scope is implemented differently from a regular CFML struct. Function members get lost or throw errors during serialization.

### Application Scope Unreliable During onApplicationEnd() Teardown (Adobe CF 2023)

On Adobe CF 2023, `onApplicationEnd()` fires synchronously during `applicationStop()` teardown (triggered by a `?reload` restart or idle-timeout reclaim). Inside that teardown the live `application` scope is no longer reliable — bare `application.wo` can resolve against a stale/torn-down scope and land on a Java `String[]`, throwing `Element wo is undefined in a Java object of type class [Ljava.lang.String;` and erroring the whole site until a CF service restart.

The only dependable reference at shutdown is the passed-in `arguments.applicationScope` (already used by the `$wheelsBrowserLauncher` cleanup in the same handler). Route all `onApplicationEnd()` calls through it and guard with `StructKeyExists` so a partially reclaimed scope degrades to a no-op instead of a hard error. Lucee 6/7 and BoxLang are unaffected; this only manifests on Adobe CF during real teardown (issue #3379).

```cfm
// WRONG — bare application.wo breaks during Adobe CF applicationStop() teardown
public void function onApplicationEnd(struct ApplicationScope) {
application.wo.$include(
template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm",
argumentCollection = arguments
);
}

// RIGHT — use the passed-in scope, the only reliable reference at shutdown
public void function onApplicationEnd(struct ApplicationScope) {
if (
StructKeyExists(arguments.applicationScope, "wo")
&& StructKeyExists(arguments.applicationScope, "wheels")
&& StructKeyExists(arguments.applicationScope.wheels, "eventPath")
) {
arguments.applicationScope.wo.$include(
template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm",
argumentCollection = arguments
);
}
}
```

**Existing apps**: apply this same change to `public/Application.cfc` — the CLI template (`wheels new`) and the demo app were fixed in Wheels 4.x (#3380).

### Closure `this` Captures Declaring Scope

CFML closures bind `this` to the component where they are DEFINED, not where they are ASSIGNED. This trips up test code that dynamically adds methods.
Expand Down Expand Up @@ -347,6 +379,42 @@ public void function $myTagWrapper() {

**Reference fix**: [#2756](https://github.com/wheels-dev/wheels/pull/2756) — adds `$responseCommitted()` and applies the defensive shape to `$header()` and `$content()`.

### A Parameter Named `request` Makes the Bare `request` Token Ambiguous (Adobe CF 2025)

In a function that declares a parameter named `request`, Adobe CF 2025 does **not** resolve the bare `request` token consistently across expression positions. Passed as a function argument it can resolve to the built-in `request` scope, while a `request.x` member-access expression in the same function resolves to `arguments.request`. A guard written in one position therefore cannot protect an access written in the other — the guard passes and the access throws.

```cfm
public string function handle(required struct request, required any next) {

// WRONG — the two `request` tokens can resolve to different things on Adobe 2025.
// StructKeyExists sees the key on the built-in scope and returns true; the
// member-access expression then resolves to arguments.request, which has no
// `wheels` key, and throws `Element WHEELS is undefined in REQUEST`.
if (StructKeyExists(request, "wheels")) {
StructDelete(request.wheels, "tenant");
}

// RIGHT (a) — IsDefined string-resolves the whole dotted path in a single
// evaluation, so the guard and the access cannot disagree.
if (IsDefined("request.wheels.tenant")) {
StructDelete(request.wheels, "tenant");
}

// RIGHT (b) — assign before use, so the key exists on that path regardless
// of how the token resolved.
if (!StructKeyExists(request, "wheels")) {
request.wheels = {};
}
request.wheels.tenant = local.tenant;
}
```

**Why**: `wheels.middleware.MiddlewareInterface` fixes the signature `handle(required struct request, required any next)`, so *every* middleware component has a parameter named `request` and is exposed to this. Lucee 6/7, BoxLang and Adobe CF 2023 all resolve the bare token to the built-in scope in both positions, so **a green local Lucee run and green Adobe 2023 smokes do NOT cover this** — only the Adobe 2025 matrix legs catch it.

Note the interaction with anti-pattern 11 (reserved scope names shadowing parameters): that entry says never *name* a parameter after a reserved scope. Middleware can't follow that rule — the interface mandates it — so middleware must instead follow the two safe patterns above and never mix them with a bare-token guard.

**Reference fix**: [#3338](https://github.com/wheels-dev/wheels/pull/3338) — the tenant-context hardening for [#3336](https://github.com/wheels-dev/wheels/issues/3336) added a `StructKeyExists(request, "wheels")` guard to `TenantResolver.handle()`; it errored on all five Adobe 2025 database legs (8 specs each) while every other engine stayed green, and was switched to the `IsDefined()` form already used by the same function's `finally` block.

## Database-Specific Gotchas

### H2 Database (Test Default)
Expand Down
9 changes: 8 additions & 1 deletion .ai/wheels/snippets/model-snippets.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,17 @@ function recent(days=30) {
## Calculated Properties
```cfm
function config() {
// SQL-based calculated property
// SQL-based calculated property — included in every SELECT by default
property(name="orderTotal", sql="(SELECT SUM(amount) FROM order_items WHERE order_id = orders.id)");

// Keep off the hot path with select=false; opt in per-call with includeCalculated
property(name="fullName", sql="firstName || ' ' || lastName", select=false);
}

// Opt a select=false property back into one finder (additive — base columns still selected)
user = model("User").findOne(includeCalculated="fullName");
order = model("Order").findAll(includeCalculated="orderTotal,shippingCost");

// Method-based calculated property
function displayName() {
if (Len(this.nickName)) {
Expand Down
1 change: 1 addition & 0 deletions .ai/wheels/testing/browser-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,6 @@ bash tools/test-local.sh # skips browser specs if JARs missin
- **Data URLs work for most tests** — no server needed for ~95% of DSL coverage. Full HTTP integration (cookies, form submits, redirects) needs a running fixture app; that wiring is the same as Wheels Web app bootstrap (separate server + baseUrl).
- **`this.browserTestSkipped`** — when Playwright JARs aren't installed (fresh CI, clean machine), `beforeAll` sets this flag and `browserDescribe`'s hooks short-circuit. All `it`s should check `if (this.browserTestSkipped) return;` to stay green on CI.
- **CI runs browser tests** — `pr.yml` and `snapshot.yml` install Playwright JARs + Chromium (cached via `browser-manifest.json` hash). Browser specs run as part of the normal test suite. `WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007` is set automatically. The base URL is resolved at instance time through a layered lookup (`this.baseUrl` → Wheels setting → JVM property `wheels.browserTest.baseUrl` → env var → CGI auto-detect → `http://localhost:8080`); per-spec `this.baseUrl` takes priority over the env var. Set `this.baseUrl` in the component pseudo-constructor (outside any function), not inside `beforeAll()` — `super.beforeAll()` calls `$resolveBaseUrl()` and caches the result, so a `this.baseUrl =` assignment that runs after `super.beforeAll()` is silently ignored.
- **Isolated application context (#3374)** — `BrowserTest.$startBrowserContext()` sends `X-Wheels-Test-Context` (Playwright `extraHTTPHeaders`) plus a `WHEELS_TEST_CONTEXT` cookie so fixture HTTP binds `<this.name>_wheelsTest`, not the live app. `Application.cfc` must include `vendor/wheels/events/testcontext.cfm` after `config/app.cfm` (ships in `wheels new`). Without that snippet, browser tests still work via the #3373 live-scope swap.
- **Fixture routes** — `/_browser/login-as` and `/_browser/logout` are mounted automatically in test mode. They must come before `.wildcard()` in routes.cfm. In the Routes UI (`/wheels/routes`) all `/_browser/*` routes appear under the **Internal** tab, not Application. The `/_browser/login-as` handler is configurable: `set(browserLoginAsHandler = "AuthFixture##loginAs")` in `config/settings.cfm` substitutes that `Controller##action` at route-registration time (default is `BrowserTestLogin##create`). Env-gating is handled by `wheels.middleware.BrowserTestFixtureGuard` on the whole `/_browser` scope — custom handlers do not need to re-implement the guard. Empty string or absent setting falls back to the default. (#2830)
- **Dialogs are Lucee-only** — `acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines.
179 changes: 179 additions & 0 deletions .claude/workflows/triage-discussions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
export const meta = {
name: 'triage-discussions',
description: 'Fetch recent GitHub Discussions, triage each thread, and adversarially verify which ones actually need a code fix, doc update, or new issue',
whenToUse: 'Periodic (e.g. monthly) sweep of community GitHub Discussions to surface genuinely actionable items — without filing duplicates or acting on reports that are already fixed/answered. Read-only: produces a verified findings report, posts nothing.',
phases: [
{ title: 'Fetch', detail: 'pull + filter recent discussions (drop Announcements and bot reports)' },
{ title: 'Triage', detail: 'read each discussion thread, classify, propose action', model: 'sonnet' },
{ title: 'Verify', detail: 'adversarially check each proposed action against current code + open issues' },
],
}

// ---------------------------------------------------------------------------
// args (all optional). Accepts an object, a JSON string, or a bare date string:
// { repo: "owner/name", since: "YYYY-MM-DD", max: 40 }
// "2026-01-01" -> treated as { since }
// Defaults: repo=wheels-dev/wheels, since=2025-01-01, max=40
// ---------------------------------------------------------------------------
const opts = (() => {
if (args == null) return {}
if (typeof args === 'string') {
const s = args.trim()
if (s.startsWith('{')) { try { return JSON.parse(s) } catch (e) { return {} } }
return s ? { since: s } : {}
}
return args
})()
const REPO = opts.repo || 'wheels-dev/wheels'
const SINCE = opts.since || '2025-01-01'
const MAX = opts.max || 40
const [OWNER, NAME] = REPO.split('/')

const CANDIDATES_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
candidates: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
number: { type: 'integer' },
title: { type: 'string' },
category: { type: 'string' },
comments: { type: 'integer' },
isAnswered: { type: 'boolean' },
updatedAt: { type: 'string' },
author: { type: 'string' },
},
required: ['number', 'title', 'category', 'comments', 'isAnswered', 'updatedAt', 'author'],
},
},
},
required: ['candidates'],
}

const TRIAGE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
number: { type: 'integer' },
classification: { type: 'string', enum: ['bug', 'feature-idea', 'doc-gap', 'question-unanswered', 'question-answered', 'onboarding-friction', 'infra', 'noise'] },
summary: { type: 'string' },
resolvedInThread: { type: 'boolean' },
proposedAction: { type: 'string', enum: ['code-fix', 'doc-update', 'file-issue', 'reply', 'close-stale', 'none'] },
actionDetail: { type: 'string' },
severity: { type: 'string', enum: ['high', 'medium', 'low'] },
needsVerification: { type: 'boolean' },
},
required: ['number', 'classification', 'summary', 'resolvedInThread', 'proposedAction', 'actionDetail', 'severity', 'needsVerification'],
}

const VERDICT_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
number: { type: 'integer' },
verdict: { type: 'string', enum: ['confirmed-needs-action', 'already-fixed', 'already-tracked', 'wont-act', 'insufficient-evidence'] },
evidence: { type: 'string' },
existingIssue: { type: 'string' },
recommendedAction: { type: 'string' },
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
},
required: ['number', 'verdict', 'evidence', 'existingIssue', 'recommendedAction', 'confidence'],
}

function fetchCmd(num) {
return `gh api graphql -f query='query($num: Int!) { repository(owner:"${OWNER}", name:"${NAME}") { discussion(number:$num) { title url createdAt updatedAt category{name} answer{ body author{login} } body comments(first:50){ nodes{ author{login} createdAt body replies(first:20){ nodes{ author{login} body } } } } } } }' -F num=${num}`
}

const fetchPrompt = [
`Build the candidate list of GitHub Discussions to triage for repo ${REPO}.`,
``,
`Run this in Bash (the API returns the 100 most-recently-updated; that is enough for a recency sweep):`,
`gh api graphql -f query='query { repository(owner:"${OWNER}", name:"${NAME}") { discussions(first:100, orderBy:{field:UPDATED_AT, direction:DESC}) { nodes { number title url createdAt updatedAt isAnswered category{name} comments{totalCount} author{login} } } } }'`,
``,
`Filter the nodes:`,
`- EXCLUDE category == "Announcements" (maintainer marketing / release posts — not actionable).`,
`- EXCLUDE author login "github-actions" (automated monthly metrics reports).`,
`- KEEP only discussions whose updatedAt >= ${SINCE}.`,
`Sort the kept set by updatedAt descending and return at most ${MAX}.`,
``,
`For each kept discussion emit: number, title, category (name), comments (the comments.totalCount integer), isAnswered (use false when the GraphQL value is null), updatedAt (date only, "YYYY-MM-DD"), author (login).`,
`Return ONLY the structured object {candidates: [...]}.`,
].join('\n')

function triagePrompt(c) {
return [
`You are triaging a GitHub Discussion in the Wheels CFML framework repo (${REPO}). Wheels is on the develop branch; many older threads reference 3.x.`,
`Discussion #${c.number}: "${c.title}" — category=${c.category}, comments=${c.comments}, updated=${c.updatedAt}, author=${c.author}.`,
``,
`STEP 1 — Read the FULL thread. Run this in Bash:`,
fetchCmd(c.number),
``,
`STEP 2 — Understand what the user reports or asks, and whether the thread already resolved it (accepted answer, "thanks that worked", maintainer fix linked, etc).`,
``,
`STEP 3 — Classify and decide whether the maintainer needs to DO something NOW.`,
`- classification: bug | feature-idea | doc-gap | question-unanswered | question-answered | onboarding-friction | infra | noise`,
`- proposedAction: code-fix | doc-update | file-issue | reply | close-stale | none`,
`- needsVerification: TRUE when the proposed action depends on the current state of the codebase or on whether an issue already exists (any code-fix / file-issue / doc-update claim a downstream verifier must confirm against HEAD). FALSE for pure social replies, clear no-ops, or answered questions that revealed nothing latent.`,
``,
`Be conservative: an answered Q&A with an accepted answer usually needs no action UNLESS it exposes a real doc gap or a latent bug. A "broken"/"doesn't work" title is a CLAIM, not a confirmed bug — flag it for verification rather than asserting it.`,
`summary: 1-2 sentences. actionDetail: the concrete next step (or "none"). severity: high|medium|low (impact on users).`,
`Return ONLY the structured object.`,
].join('\n')
}

function verifyPrompt(t, c) {
return [
`You are an ADVERSARIAL verifier for the Wheels CFML framework (${REPO}); working tree is the develop branch. DEFAULT TO SKEPTICAL — assume the proposed action is unnecessary until evidence proves otherwise.`,
`A triage agent reviewed Discussion #${c.number} ("${c.title}") and proposed: ${t.proposedAction} — "${t.actionDetail}" (classification: ${t.classification}, severity: ${t.severity}).`,
`Thread summary: ${t.summary}`,
``,
`Verify against reality, citing evidence:`,
`1. ALREADY FIXED? Search the codebase (Grep/Glob/Read) and recent history (git log --oneline -40). The reported behavior may already be patched. Re-read the thread if useful: ${fetchCmd(c.number)}`,
`2. ALREADY TRACKED? Run: gh issue list --repo ${REPO} --state all --search "<relevant keywords>" --limit 15 (try a couple of keyword sets).`,
`3. Only if it is a REAL, untracked, unfixed problem -> confirmed-needs-action.`,
``,
`verdict: confirmed-needs-action | already-fixed | already-tracked | wont-act | insufficient-evidence`,
`evidence: cite a file:line, commit SHA, or issue number that justifies the verdict (be specific). existingIssue: issue number if already-tracked, else "".`,
`recommendedAction: the crisp final recommendation for the maintainer. confidence: high|medium|low.`,
`READ-ONLY: do NOT post anything to GitHub, do NOT edit files. Return ONLY the structured object.`,
].join('\n')
}

phase('Fetch')
const fetched = await agent(fetchPrompt, { label: 'fetch-discussions', phase: 'Fetch', schema: CANDIDATES_SCHEMA, effort: 'low' })
const candidates = (fetched && fetched.candidates) || []
log(`Fetched ${candidates.length} candidate discussions (updated since ${SINCE}, excluding Announcements + bots).`)
if (!candidates.length) return { reviewed: 0, confirmedCount: 0, findings: [] }

phase('Triage')
const results = await pipeline(
candidates,
(c) => agent(triagePrompt(c), { label: `triage:#${c.number}`, phase: 'Triage', schema: TRIAGE_SCHEMA, model: 'sonnet', effort: 'medium' }),
(t, c) => {
if (!t) return { number: c.number, candidate: c, triage: null, verdict: null }
const actionable = t.needsVerification && t.proposedAction !== 'none' && t.classification !== 'noise'
if (!actionable) {
return {
number: c.number, candidate: c, triage: t,
verdict: {
number: c.number, verdict: 'wont-act',
evidence: 'triage: no codebase/issue verification needed',
existingIssue: '',
recommendedAction: t.proposedAction === 'none' ? 'No action needed' : t.actionDetail,
confidence: 'medium', skippedVerify: true,
},
}
}
return agent(verifyPrompt(t, c), { label: `verify:#${c.number}`, phase: 'Verify', effort: 'high', schema: VERDICT_SCHEMA })
.then(v => ({ number: c.number, candidate: c, triage: t, verdict: v }))
}
)

const findings = results.filter(Boolean)
const confirmed = findings.filter(f => f.verdict && f.verdict.verdict === 'confirmed-needs-action')
log(`Done: ${findings.length} reviewed, ${confirmed.length} confirmed as needing action.`)
return { reviewed: candidates.length, confirmedCount: confirmed.length, findings }
2 changes: 1 addition & 1 deletion .github/actions/setup-wheels-test-env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ inputs:
lucli-version:
description: 'LuCLI release version'
required: false
default: '0.3.7'
default: '0.6.1'
install-playwright:
description: 'Install Playwright + Chromium (set "false" to skip if no browser tests run)'
required: false
Expand Down
Loading
Loading