Skip to content

Release 4.0.1 — merge develop into main - #2769

Merged
bpamiri merged 105 commits into
mainfrom
release/v4.0.1-to-main
May 20, 2026
Merged

Release 4.0.1 — merge develop into main#2769
bpamiri merged 105 commits into
mainfrom
release/v4.0.1-to-main

Conversation

@bpamiri

@bpamiri bpamiri commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

The v4.0.1 GA cut. Merges develop into main; mirrors #2607's pattern.

After this PR merges (via "Create a merge commit", not squash), release.yml fires on the push to main, softprops/action-gh-release creates the v4.0.1 tag at main HEAD, and the downstream dispatches kick off (Homebrew tap PR, Scoop bucket PR, bump-develop-version.yml repository_dispatch).

Pre-flight

  • CHANGELOG ready#2768 landed on develop, promoting ## [Unreleased] to # [4.0.1] => 2026-05-20 and fixing the ------- separator so release.yml's awk '/^# \[VERSION\]/,/^---$/' extraction terminates correctly.
  • wheels.json version is clean4.0.1, no -snapshot suffix (auto-bumped by bump-develop-version.yml after the v4.0.0 GA).
  • No => TBD in CHANGELOG for 4.0.1 — release.yml's release-checklist validation step passes.
  • Archive branches pushedarchive/develop-pre-v4.0.1 (63777a0a5), archive/main-pre-v4.0.1 (91e7398c7) — keep for at least one cycle in case rollback is needed.
  • Clean mergegit diff origin/develop release/v4.0.1-to-main is empty: the merge result's tree is byte-identical to develop HEAD. The v4.0.0 cut's tree-alignment dance left main's tree byte-identical to the v4.0.0 tag, so a plain --no-ff -X theirs produced a clean forward-merge of develop's 103+1 new commits with no conflicts.
  • Release-notes awk dry-runawk '/^# \[4\.0\.1\]/,/^---$/' CHANGELOG.md | wc -l = 85 lines; section terminates at the new --- separator immediately before # [4.0.0].
  • develop CI green — last develop push CI passed in 8m31s.

What's in 4.0.1

103 commits / ~97 unique PR references since the v4.0.0 tag (8 days). Highlights:

Adobe ColdFusion 2023/2025 compatibility hardening

  • cfheader attributeCollection trap — Adobe 2023/2025 reject arguments scope as attributeCollection; affects every cfheader / cfcontent / cfmail / cfdirectory / cffile / cflocation wrapper. 13 sites in vendor/wheels/Global.cfc patched uniformly. $header() was catastrophic because it runs on every request. (#2750)
  • env() helper — second parameter was named default, a CFML reserved word; Adobe CF refuses to bind a parameter with that name, silently returning "" for every call. Renamed to defaultValue; back-compat preserved for the named-arg form. (#2756)
  • Vite asset-walk — Adobe CF copies arrays by value in struct literals, so $viteWalkImports(preloads = local.rv.preloads, ...) handed the walker independent copies. Fix passes the parent struct ref. (#2756)
  • BoxLang lockingSpec catch-scope discard (#2754)

Windows Scoop install reliability

  • wheels.cmd cmd.exe pre-parser tripped on JAR ZIP byte sequences in the bat-jar concatenation; fix invokes java.exe -jar directly. (#2766)
  • Release artifacts now ship *.zip.sha512 / *.zip.md5 checksum sidecars (was *.sha512 / *.md5) so the scoop-wheels $url.sha512 substitution no longer 404s. (#2761)
  • .module-version baked at build time, not from source (#2764); scoop bucket add java documented as prereq (#2762).
  • Stale draft Scoop manifests removed; canonical bucket repo is authoritative (#2767).

paginationNav() framework presets

  • viewStyle argument with "plain", "bootstrap5", "bootstrap4", "tailwind" presets (#2731).
  • Anchor modes default to window-aware auto-suppress (#2733).
  • Unknown sub-helper arguments rejected in development (#2726).
  • Bootstrap-style prepend/append/per-anchor wrappers preserved (#2730).

Package system

  • Plural mappings in package.json for legacy CFML aliases (#2705, #2739).
  • Auto-register per-package CFML mapping in PackageLoader (#2712, #2732).
  • wheels packages add documented as canonical install verb; install stops appearing in --help (#2723, #2729).

CLI wheels deploy

  • Flat aliases for bootstrap/exec/fetch-secrets/extract-secrets/print-secrets to dodge picocli root-token absorption (#2690, #2699, #2702).
  • --release alias for --version (#2691).
  • Dockerfile / .dockerignore scaffolding in wheels deploy init (#2686).
  • $gitShortSha() returns "unknown" on non-zero git exit (#2689).

Compat-matrix robustness

  • CockroachDB advisory-lock capability override (#2746, #2747).
  • Oracle insertAll uses INSERT ALL form; renameSystemTables skips transaction wrapper (#2749, #2753).
  • MySQL carve-out in addColumnOptionsSpec text-default assertion (#2748).
  • Oracle compat-matrix unbroken — Lucee 6 dbdriver + cascade drops (#2692).
  • Migrator system tables dropped between specs (#2685).

Middleware

  • Cors no longer emits raw comma list as Access-Control-Allow-Origin (#2725).
  • Cors emits Vary: Origin when reflecting allowed origins (#2724).
  • OPTIONS preflight short-circuit when CORS middleware registered (#2728).
  • RateLimiter validates windowSeconds and maxRequests at construction (#2695).

Model

  • whereIn / whereNotIn short-circuit on empty arrays (was JDBC syntax error) (#2736).

Merge mechanics

  • Strategy: git merge --no-ff -X theirs --signoff origin/develop. The -X theirs is a safety net (mirrors v4.0.0); no actual conflicts because main's tree was already byte-identical to the v4.0.0 tag.
  • Merge SHA: ca4ee38cb420062f737205f816b337526314c40a (on this branch).
  • Merge button: "Create a merge commit" — not squash. Mirrors Release 4.0.0 — merge develop into main #2607. The push of the merge commit to main is what fires release.yml.

After merge — automation cascade

  1. release.yml fires on push to main, builds artifacts (wheels-core, wheels-cli, wheels-base-template, wheels-starter-app, wheels-module, .deb, .rpm), publishes to GitHub Releases. softprops/action-gh-release creates the v4.0.1 tag at main HEAD.
  2. release.yml's "Dispatch downstream package managers" step fires three repository_dispatch events via DOWNSTREAM_DISPATCH_TOKEN:
    • wheels-releasedwheels-dev/homebrew-wheels → brew formula bump PR
    • wheels-releasedwheels-dev/scoop-wheels → scoop bucket PR
    • bump-developwheels-dev/wheelsbump-develop-version.yml opens a PR against develop bumping wheels.json to 4.0.2
  3. Linux repo bucket dispatches via LINUX_REPO_DISPATCH_TOKEN (apt.wheels.dev / yum.wheels.dev metadata regen).

Total maintainer turnaround: merge 3 downstream auto-PRs across the tap/bucket repos + the develop bump PR. ~10 minutes.

Post-release smoke test (per RELEASE_PLAYBOOK.md)

brew update && brew upgrade wheels
wheels --version    # should report 4.0.1 (stable)

mkdir /tmp/release-smoke && cd /tmp/release-smoke
wheels new myapp && cd myapp && wheels start
# Visit http://localhost:8080 — should see the welcome page.

Test plan

  • CI (Wheels Pull Requests + bot reviewers) on this PR is green before merge.
  • After merge: release.yml workflow run succeeds end-to-end on the main-branch push.
  • v4.0.1 tag created at main HEAD; GitHub Release published with the awk-extracted notes from CHANGELOG.md.
  • Homebrew/Scoop downstream auto-PRs open within ~5 min of release publish.
  • bump-develop-version.yml opens the 4.0.2 bump PR via repository_dispatch (the fix for bump-develop-version.yml doesn't trigger when release.yml uses GITHUB_TOKEN to publish #2609 is now live on develop — first GA cut testing it).

🤖 Generated with Claude Code

bpamiri and others added 30 commits May 11, 2026 23:15
Manual replacement for the `bump-develop-version.yml` PR that didn't fire
on the v4.0.0 GA. The workflow triggers on `release: published` events,
but neither the original release.yml-created release nor the subsequent
pre-release toggle (web UI and `gh release edit` both tried) dispatched
an event Actions could pick up — `softprops/action-gh-release` used the
default `GITHUB_TOKEN`, which can't trigger downstream workflows
(documented Actions limitation), and the post-publish toggle didn't fire
a fresh `release: published` either.

Sets `wheels.json` to `4.0.1` so subsequent develop snapshots resume
tagging correctly as `4.0.1-snapshot.<run>`. The maintainer makes the
actual scope decision at the next GA's tag-cut time — this is just the
snapshot baseline. If the next GA ends up being a minor or major bump,
snapshot version strings still sort strictly lower than any of those.

Follow-up: add `workflow_dispatch` to `bump-develop-version.yml` or
switch its trigger to something `GITHUB_TOKEN` can fire (e.g.,
`repository_dispatch`).

Signed-off-by: Peter Amiri <peter@alurium.com>
chore: bump develop snapshot target to 4.0.1
…kages install` verb

LuCLI's built-in extension installer intercepts the literal subcommand
`install` across every module, so `wheels packages install <name>` never
reaches Module.cfc — it prints "No git or extension dependencies to
install" and exits without installing anything. The canonical install
verb is `wheels packages add` (this is already documented in the
PackagesMainCli header and in CLAUDE.md's packages quick reference).

The generated-app templates shipped via `wheels new` still pointed users
at the broken verb in two places — `_gitignore` and `app/plugins/README.md`
— so every new app inherited misleading help text. Reword both to point
at `wheels packages add`.

Closes the CLI-side half of #2610. Docs sync (MDX guides, `.ai/wheels/`,
`CLAUDE.md`) is handled separately by bot-update-docs.yml.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…`wheels packages add`

Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
The v4.0.0 GA shipped today (2026-05-12). Renaming the URL slug from
'v4-0-0-snapshot' to 'v4-0-0' so the URL matches the now-stable channel
status. Internal cross-link rewrites, astro redirects for the old URLs,
versions.ts updates, and the new v4-0-1-snapshot clone follow in
subsequent commits in this PR.

Signed-off-by: Peter Amiri <peter@alurium.com>
…d dir to /v4-0-0/

After the directory rename in the previous commit, all the absolute
cross-page links inside the docs still pointed at the old URL slug.
Replaced /v4-0-0-snapshot/ with /v4-0-0/ in every .md/.mdx file under
the renamed directory and in the renamed sidebar JSON. Each file's
within-version structure is unchanged — same relative paths, same
fragment anchors — just the version segment is corrected.

Signed-off-by: Peter Amiri <peter@alurium.com>
versions.ts (single source of truth for both guides and api sites):
- GUIDES_VERSIONS: replace v4-0-0-snapshot (snapshot) with v4-0-0 (current),
  demote v3-0-0 to archived, add new v4-0-1-snapshot (snapshot) for the
  next dev cycle.
- API_VERSIONS: replace v4-0-0-snapshot (snapshot) with v4-0-0 (current),
  demote v3-0-0 to archived. No v4-0-1-snapshot yet because there's no
  docs/api/v4.0.1.json to generate against; the API site only adds a
  version slug when there's matching JSON to consume.

astro.config.mjs:
- Add explicit redirects for the high-traffic /v4-0-0-snapshot/ landing
  pages (root, installing, tutorial, first-15-minutes, release-channels,
  cfml-engines, command-line-tools/installation) so external bookmarks
  and SEO equity transfer to the new /v4-0-0/ slug.
- Update the two Phase-0 cli-reference redirects to target /v4-0-0/
  instead of /v4-0-0-snapshot/. Add the same redirect at the new slug too
  in case anyone hits /v4-0-0/cli-reference directly.

Astro's static redirects map can't catch-all without getStaticPaths, so
arbitrary deep links to /v4-0-0-snapshot/foo/bar/baz will 404 rather than
redirect. The listed entries cover the URLs most likely to appear in
external content (blog posts, README links, package descriptions). A
Cloudflare-side wildcard could provide full coverage if SEO retention
from deep-link backlinks turns out to matter post-GA.

Signed-off-by: Peter Amiri <peter@alurium.com>
Ran `node web/scripts/generate-api-docs.mjs 4.0.0` to populate
web/sites/api/src/content/docs/v4-0-0/ with 378 function pages across 9
sections (configuration, controller, global-helpers, migrator, model-*,
view-helpers, etc.). The input JSON at docs/api/v4.0.0.json was already
present from the 4.0 prep work — the API site just hadn't been generated
against it.

Without this, versions.ts listing slug v4-0-0 would have 404'd on the
API site since no content directory existed there. The generator output
is deterministic from the JSON, so re-running it on the same input
produces byte-identical results.

Signed-off-by: Peter Amiri <peter@alurium.com>
…e of v4-0-0/

At GA time, the next-snapshot docs are byte-identical to the just-shipped
stable docs (modulo the URL slug). As fixes and new features for 4.0.1
land on develop, the two directories diverge. This commit establishes
that baseline:

- cp -r v4-0-0/ v4-0-1-snapshot/                 — full 184-file clone
- cp v4-0-0.json v4-0-1-snapshot.json            — matching sidebar
- find ... sed 's|/v4-0-0/|/v4-0-1-snapshot/|g'  — rewrite internal
  absolute links inside the cloned dir so self-references resolve
  within the snapshot namespace (relative links untouched — they don't
  need version segment rewriting)

The dropdown entry from versions.ts now resolves to a real, fully-
populated tree instead of 404'ing. When the first 4.0.1-era doc change
lands, it edits files in v4-0-1-snapshot/ specifically; v4-0-0/ remains
the frozen stable docs.

Signed-off-by: Peter Amiri <peter@alurium.com>
After renaming the snapshot directory to v4-0-0 and seeding
v4-0-1-snapshot as the new dev clone, sweep the rest of the repo for
references to the retired slug:

- web/content/blog/posts/*.md (6 posts) — outbound URLs to
  /v4-0-0-snapshot/... rewritten to /v4-0-0/... so blog links now point
  at the stable docs the posts were written against. Redirects in
  astro.config.mjs handle anyone hitting the old URLs directly.
- web/sites/guides/src/content/docs/index.md — version list now leads
  with v4.0.0 (current), then v4.0.1-SNAPSHOT (dev), with v3 and v2.5
  pushed down to archived.
- web/sites/api/src/content/docs/index.md — version table cleaned up:
  v4.0.0 promoted to "current release" with 378 functions (matches
  generator output from previous commit), v3.0.0 demoted to archived.
- web/sites/guides/src/content/docs/{v2-5-0,v3-0-0}/index.md — "go to
  current docs" cross-links now point at /v4-0-0/start-here/installing/
  instead of the snapshot URL.
- web/sites/guides/STYLE.md — contributor-facing doc folder reference
  now points at v4-0-1-snapshot/ (where new doc PRs land).
- web/packages/ui/src/data/versions.ts (JSDoc example), EditLink.astro
  (JSDoc), PageTitle.astro (JSDoc) — example slugs in code comments
  refreshed to v4-0-1-snapshot to match what readers see in URLs now.
- web/sites/guides/scripts/verify-docs/verify-docs.mjs — DEFAULT_TARGET
  → src/content/docs/v4-0-1-snapshot (active dev is the right default
  for verify-checks).
- web/scripts/visual-regression.mjs — guides canary URL switched to
  /v4-0-0/start-here/tutorial/ to match the API site's "canary watches
  the stable docs" pattern.

The only file that still mentions v4-0-0-snapshot is astro.config.mjs,
which is intentional — those entries are the redirects that catch
external bookmarks pointing at the old URL.

Signed-off-by: Peter Amiri <peter@alurium.com>
- Fix stale sidebar filename references in v4-0-1-snapshot writing-docs
  guide (lines 18, 162, 164, 194): point contributors at
  src/sidebars/v4-0-1-snapshot.json (the active snapshot sidebar)
  rather than v4-0-0.json (the frozen stable sidebar).
- Update the FileTree in the v4-0-1-snapshot writing-docs guide
  (lines 33, 46) to surface both the v4-0-0/ and v4-0-1-snapshot/
  content directories alongside both sidebar JSON files, so a
  contributor following the guide can see the parallel-tree layout
  at a glance.
- Repair the broken predicate on line 27 of both
  v4-0-0/contributing/writing-docs.mdx and
  v4-0-1-snapshot/contributing/writing-docs.mdx ("the next
  in-development snapshot is X will sit beside it" → "the next
  in-development snapshot, X, sits beside it").

Doc-only change; no framework code, no tests, no cross-engine surface.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…version-slug rename

The CI run on the previous head of this branch reported:
  [guides] FAIL — 10,643 pixels differ (threshold: 300)
  [api]    FAIL —  6,167 pixels differ (threshold: 300)

Reviewed the diff artifact at:
  https://github.com/wheels-dev/wheels/actions/runs/25720017511

Confirmed both diffs are entirely confined to the page chrome — the
header version-dropdown text ("v4.0.0-SNAPSHOT (dev)" → "v4.0.1-SNAPSHOT
(dev)") and the left-sidebar version label ("v3.0.0 (current)" → just
"v3.0.0", since v3 is now archived). The page body content is byte-
identical on both screenshots; the pixel differences are pure chrome
rendering driven by the versions.ts updates already on this branch.

Copied the CI-rendered .actual.png files from the visual-regression-diffs
artifact directly into web/tests/visual-baselines/, per the comment in
.github/workflows/web-deploy.yml that documents this exact procedure
("download the visual-regression-diffs artifact and copy each *.actual.png
over the matching web/tests/visual-baselines/*.png, commit, and push").

Using the CI-captured PNGs (not a local refresh) sidesteps the Linux vs
macOS font-rendering drift the script's --update mode flags as a caveat.

Signed-off-by: Peter Amiri <peter@alurium.com>
…-install-command-does-not-install-p

fix(cli): generated-app templates stop advertising broken `wheels packages install` verb
Pulls in PR #2611 ("wheels packages install" → "wheels packages add"
cleanup) plus its companion CHANGELOG entry, _gitignore template fix,
plugins/README cleanup, and the new CliTemplateInstallVerbSpec test.

Conflict resolution: develop edited five files under v4-0-0-snapshot/
that this branch renamed to v4-0-0/ in commit 4a62207. Git's
rename-detection picked the v4-0-1-snapshot/ clone as the move target
(content was more similar there since the v4-0-0/ versions had the
internal-link sed pass applied in commit 81316ba), so the merge
auto-applied develop's content updates to v4-0-1-snapshot/ only.

Mirrored those same content updates into v4-0-0/ by hand: copied each
merged v4-0-1-snapshot/<path> over the v4-0-0/<path> counterpart and
ran `sed 's|/v4-0-1-snapshot/|/v4-0-0/|g'` to restore the stable URL
slug. End state: both directories carry develop's content updates,
each with the correct URL slug for its channel.

Files affected:
- command-line-tools/commands/packages/index.mdx
- command-line-tools/commands/packages/install.mdx
- deployment/observability-and-logging.mdx
- start-here/tutorial/03-crud-scaffold.mdx
- upgrading/3x-to-4x.mdx

Verified post-resolution: v4-0-0/<path> matches v4-0-1-snapshot/<path>
modulo URL slugs (diff is purely /v4-0-0/ vs /v4-0-1-snapshot/ tokens).

Signed-off-by: Peter Amiri <peter@alurium.com>
…hot-to-stable

chore(web): post-GA docs rename — v4-0-0-snapshot → v4-0-0, seed v4-0-1-snapshot
#2619)

* ci: let medium-confidence triages auto-fire propose-fix and write-docs

Widen the auto-fire gate on bot-propose-fix.yml and bot-write-docs.yml so
medium-confidence triage / research / docs-request markers fire the next
stage, not just high. Low-confidence stays manual.

Previously the gate doubled as a halt: medium meant "don't auto-fire."
That stopped useful work on ambiguous-but-actionable issues. The
propose-fix prompt's step-4 safety net (security / middleware /
migrations / deploy / DI / cross-engine) already aborts before opening a
PR for sensitive areas, posting a fix-held marker instead. Same pattern
in write-docs step 2 for structural docs-architecture decisions.
Reviewer A and Reviewer B then critique whatever lands, with the Senior
Advisor on deadlock — the heavier review machinery does the filtering
the confidence ceiling used to do. Human approval on develop is still
the merge gate.

Triage and research prompts now emit a :medium marker alongside the
existing :high marker; low still emits no marker. The triage
auto-downgrade rules are preserved as informational metadata for humans
skimming the comment (medium ratings still auto-fire; propose-fix's
safety net catches the sensitive cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* ci: correct write-docs safety-net step number in bot-write-docs.yml comment

Reviewer A flagged that the bot-write-docs.yml header comment said
"step-2 safety net" for the structural-docs-decision halt. The actual
step number in .claude/commands/write-docs.md is 4 — step 2 is "Read
the authoritative context." Fix the workflow comment and add the same
"step-4" qualifier to the marker reference table in
docs/contributing/wheels-bot.md so both safety-net rows are symmetrical.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): use Adobe-safe 3-arg mid() in MCP command parser (#2613)

Adobe ColdFusion requires mid(string, start, count) with all three
arguments; Lucee and BoxLang accept the 2-arg form and default count
to the remainder of the string. The MCP command executor and its
security spec both used the 2-arg form to strip the "wheels " prefix,
which crashed the entire vendor/wheels/tests/specs/security bundle on
Adobe (compile-time MID validation error) and would also crash any
MCP command execution at runtime on Adobe.

Switch all five call sites — one in vendor/wheels/public/mcp/McpServer.cfc
and four in vendor/wheels/tests/specs/security/McpCommandInjectionSpec.cfc
— to mid(s, 7, len(s)), which works identically on every supported engine.
Add an explicit regression-guard spec that documents the Adobe-safe
pattern so the bug cannot silently return.

Verified on:
- Lucee 7 + SQLite: 174/174 security specs pass
- Adobe CF 2023 + SQLite: 174/174 security specs pass (was 0 pass / bundle crash)

Fixes #2613

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(security): drop issue-number refs from MCP mid() workaround comments

CLAUDE.md says inline comments shouldn't reference the originating
task or issue ("handles the case from issue #123") — that context
belongs in the PR description and rots in the source tree. Keep the
cross-engine "why" and the verbatim Adobe error string; drop the
parenthetical issue reference.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
…rs() for Adobe CF (#2616)

* fix(test): route browser-test artifact dir creation through File.mkdirs() for Adobe CF

`BrowserTest.$captureFailureArtifacts` and `McpServer`'s test-file generator
both called `directoryCreate(path, true)`. Adobe ColdFusion rejects the
Lucee-only `createPath` flag with "Parameter validation error for the
DIRECTORYCREATE function. The function takes 1 parameter.", which crashed
the core test suite on ACF any time a browser spec failed and the artifact
directory did not yet exist.

Route the recursive directory creation through `java.io.File.mkdirs()`,
which works uniformly on Lucee, Adobe CF, and BoxLang — same canonical
workaround already used by `services/packages/ManifestCache.cfc::$ensureDir`
(#2567).

Adds `BrowserTestArtifactDirSpec` covering the nested-parent and
already-exists cases against the extracted `$ensureArtifactDir` helper.

Fixes #2614

Signed-off-by: Peter Amiri <petera@pai.com>

* refactor(test): tighten browser-test artifact dir fix per review feedback

Reviewer A flagged four items on PR #2616:

1. McpServer.$ensureDir now matches ManifestCache's canonical mkdirs +
   re-check + throw pattern, so a silent mkdirs() failure surfaces as
   "Could not create test directory" instead of a downstream FileWrite
   error.
2. Drop the pre-existing "Debug logging removed - path resolution
   working correctly" meta-comment that violated CLAUDE.md's "no
   references to past changes" rule.
3. Shrink the $ensureArtifactDir Javadoc block to a single line per
   CLAUDE.md's "one short line max" guidance.
4. Compress the regression-context comment in BrowserTestArtifactDirSpec
   from 10 lines to 2, while preserving the issue reference in both the
   comment and the it() label.

Note: the it() label now uses `##2614` because unescaped `#` in CFML
string literals triggers the "Invalid Syntax Closing [##] not found"
trap and crashes the entire spec bundle — verified locally.

The shared-beforeEach suggestion was skipped (reviewer marked it
"harmless here" and YAGNI applies — `new BrowserTest()` has no
side-effects without Playwright).

Signed-off-by: Peter Amiri <petera@pai.com>

* refactor(test): align $ensureArtifactDir with ManifestCache canonical pattern

Capture mkdirs() return value and re-verify with directoryExists before
throwing, matching the reference implementation in ManifestCache.$ensureDir.
The surrounding try/catch in $captureFailureArtifacts still swallows the
throw to preserve best-effort artifact capture semantics, but the throw
now produces a diagnosable log entry before the swallow when a permission-
denied or filesystem failure occurs.

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(test): collapse two-line regression comment to one line per CLAUDE.md

Reviewer A flagged the remaining 2-line block as one thought; folding
it into a single line completes the trim from round 1.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <petera@pai.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
…2615)

* ci(release): fire bump-develop via repository_dispatch (fix #2609)

The release:published event emitted by release.yml's "Create GitHub
Release" step uses the default GITHUB_TOKEN, which (by GitHub's design)
cannot trigger downstream workflows. As a result, bump-develop-version.yml
never fired on the v4.0.0 GA cut and the maintainer had to open the
develop-bump PR manually (#2608).

Fix:
- release.yml's existing "Dispatch downstream package managers" step now
  also dispatches a `bump-develop` event at wheels-dev/wheels itself
  (only when CHANNEL=stable), piggybacking on the same PAT
  (DOWNSTREAM_DISPATCH_TOKEN) that already drives the homebrew/chocolatey
  dispatches.
- bump-develop-version.yml listens for `repository_dispatch: types:
  [bump-develop]` and adds a `workflow_dispatch` manual fallback. The
  prerelease/target-branch eligibility gates can be deleted because the
  dispatcher itself only fires for stable (which by construction comes
  from main and is non-prerelease).
- RELEASE_PLAYBOOK.md updated to document the dispatch chain plus a
  manual-fallback entry in the failure-modes table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(release): fail loud on unparseable workflow_dispatch input

Reviewer A in #2615 found a real correctness issue: the SemVer-mismatch
guard exited 0, which marks the step succeeded and lets GitHub Actions
run the unguarded "Update wheels.json" and "Open bump PR" steps with
NEW_VERSION/RELEASED_VERSION unset. Concrete failure: jq writes
'"version": ""' to wheels.json and peter-evans/create-pull-request opens
'auto-bump/develop-' with title 'chore: bump develop snapshot target to '.

The primary repository_dispatch path is safe — release.yml's CHANNEL=stable
branch only fires after -snapshot/-rc filtering, so the payload is
guaranteed bare SemVer. This only affects the workflow_dispatch fallback
when an operator types something like '4.0.1-rc1'.

Fix: exit 1 with ::error:: so the run goes red and the operator re-fires
with corrected input. Applied Reviewer A's Option A (Reviewer B converged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The title contains two colons, which without quoting causes js-yaml to
treat 'Wheels 4.0' as a key and reject the rest with `bad indentation of
a mapping entry` at line 1:17, breaking the Astro build for blog.wheels.dev.

This unblocks today's deploy. Long-term fix is in wheels-publishing-admin
PR #4 (auto-quote YAML scalars containing reserved characters in the
exporter itself).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… in scaffolded Application.cfc (#2623)

The CLI's scaffolded Application.cfc declared a local `injector` variable
in onApplicationStart() and onError() but then referenced
application.wheelsdi without an explicit assignment. The runtime only
worked because Injector.init() self-registers at application.wheelsdi —
reading the template suggested otherwise and matched the bug report in
issue #2622 ("application.wheelsdi is never assigned").

Replace the orphan local in all four templates (the canonical CLI
template and the in-repo public/, examples/tweet/, examples/starter-app/
copies) so every reference goes through application.wheelsdi. Pin the
contract with a new spec at vendor/wheels/tests/specs/cli/
ApplicationCfcInjectorAssignmentSpec.cfc so freshly scaffolded apps and
the in-repo derivatives cannot drift back into the inconsistent form.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
* fix(cli): honor --no-sqlite when scaffolding lucee.json

Drop the hardcoded SQLite datasource pair from cli/lucli/templates/app/lucee.json
in favor of a {{datasourcesBlock}} placeholder, and thread opts.noSQLite
through scaffoldNewApp's context so the substituted block is either the SQLite
pair (default) or {} when --no-sqlite is set. Lucee no longer auto-creates
db/development.sqlite / db/test.sqlite on first connection. Fixes #2621.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(web/guides): clarify lucee.json honors --no-sqlite in creating-a-project guide

The prior description implied only config/app.cfm carried datasource
configuration, leaving lucee.json's role undocumented. After #2621 both
files honor --no-sqlite: lucee.json now emits an empty datasources object
so Lucee never auto-creates db/*.sqlite on first connection.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(cli): address Reviewer A/B consensus findings (round 1)

- cli/lucli/Module.cfc: collapse 7-line comment on `var context` to a
  single-line WHY note per CLAUDE.md "one short line max" rule, and fix
  the unnecessary `##2621` escape inside the `//` comment (#2621)
- cli/lucli/Module.cfc: drop the 8-line docstring on
  buildSQLiteDatasourcesBlock(); the function name plus its single
  caller's note already explain the WHY
- cli/lucli/Module.cfc: remove the stray double-space in
  `block &= pad & "}," & nl;`
- vendor/wheels/tests/specs/cli/NoSQLiteScaffoldSpec.cfc: drop the
  17-line component-level docblock; the describe label
  "wheels new --no-sqlite (issue 2621)" already pins the regression
  context

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* test(web/blog): refresh visual baseline after Wheels 4.0 post drift

The blog homepage baseline (web/tests/visual-baselines/blog.png) drifted
when 2b908f6 (Wheels 4.0: Closing the Maturity Gap post) and 4e695b2
(title quote fix) landed on develop — the workflow's post-merge
auto-refresh in web-deploy.yml only fires when `blog_content_changed=true`
on a PR, and these were direct merges that didn't pre-refresh.

This commit refreshes the baseline to match the current rendered blog
homepage, unblocking PR #2624 (whose CI failure was inherited drift, not
a code change) and develop's last two push runs which fail identically.

The broader auto-refresh-race fix lives on peter/fix-blog-baseline-race;
this is the immediate unblock.

Signed-off-by: Peter Amiri <peter@alurium.com>

* test(web/blog): use CI-rendered baseline (macOS Chromium diverges)

The previous baseline refresh (83f7f8d) was generated on macOS
Chromium, which produces noticeably different font hinting and
antialiasing from CI's Linux Chromium — the diff measured 43,928
pixels, well above the 300-pixel threshold.

Replaced web/tests/visual-baselines/blog.png with blog.actual.png
extracted from the visual-regression-diffs artifact of the failing
CI run (run 25763885277). This is the Linux-rendered output that
subsequent CI runs will diff against, so the comparison should now
land at 0 pixels (or well within the threshold).

Verified by visual inspection: same content as the macOS render
(Wheels 4.0 / LuCLI / Multi-Tenancy posts), just rendered with the
CI environment's font stack.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…els.dev URL (#2640)

* fix(cli): point scaffolded routes.cfm comment at canonical guides.wheels.dev URL

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(test): add scaffolded routes.cfm to ConfigRoutesStaleDocUrlSpec targets (round 1)

Address Reviewer A/B consensus: add cli/lucli/templates/app/config/routes.cfm
to the targets array in ConfigRoutesStaleDocUrlSpec so the canonical-URL
regression guard also covers the already-corrected third template. Without
this, a future edit could silently reintroduce the stale /docs/routing path
in that file. All four spec assertions already hold against the file
(canonical URL present, stale path / docs.cfwheels.org / cfwheels.(org|com)
all absent).

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…essages (#2641)

* fix(config): interpolate plugin and package names in load-trace log messages

The "Loading plugin..." / "Loading package..." INFO lines in
wheels_security.log were emitting literal placeholder tokens
(`#arguments.dirName#`, `#local.pluginKey#`, etc.) instead of the
resolved values. The `WriteLog(text="...")` strings escaped each `#` as
`##` — CFML's way to write a literal `#` — so the placeholders never
ran through expression evaluation and the log was useless for diagnosing
which plugin or package was being loaded.

Drop the escaping so single-pound interpolation kicks in. Add a spec
under `vendor/wheels/tests/specs/packages/` that inspects both call
sites and fails fast if the escape pattern reappears.

Fixes #2630

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(packages): address Reviewer A/B consensus findings (round 1)

- Collapse the 13-line block comment at the top of
  vendor/wheels/tests/specs/packages/PackageLoaderLogInterpolationSpec.cfc
  to a single line per CLAUDE.md's "one short line max" rule.
- Add positive assertions in both it() blocks verifying the correct
  single-pound pattern is present in PackageLoader.cfc and Plugins.cfc
  so the spec stays meaningful if the WriteLog line is later removed.

Refs PR #2641, issue #2630.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(packages): address Reviewer A/B consensus findings (round 2)

- Collapse the 4-line escaping-logic comment in the PackageLoader it()
  block of vendor/wheels/tests/specs/packages/PackageLoaderLogInterpolationSpec.cfc
  to a single line per CLAUDE.md's "one short line max" rule.
- Collapse the 3-line positive-assertion comment in the same it() block
  to a single line: "Positive guard: fails if the WriteLog line is
  silently removed." The single-line comment in the Plugins it() block
  was already compliant.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…2644)

Blog skeleton now lists all eleven canonical breaking changes, matching
the upgrade guide's authoritative count. Fixes the test-base-class rename
entry (was mislabeled 'testbox namespace' — the actual rename is
wheels.Test → wheels.WheelsTest). Adds the previously-missing
application.wirebox → application.wheelsdi and Vite manifest strictness
breaking changes. Removes the incorrect 'not on the canonical list'
framing for the security-default items.

Stats table 'Breaking defaults hardened | 7' corrected to
'Breaking changes | 11'; four detail-row delta labels updated from
Changed / Renamed / New → Breaking to match the canonical guide.

Fixes #2632

Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…rebox rename (#2636)

Add an Aside callout to upgrade-guide item 10 stating that
wheels-legacy-adapter does not create an application.wirebox alias and
does not restore the wirebox.system.ioc.Injector package path. Add a
scope table to the Legacy Compatibility Adapter section listing what the
adapter covers versus what requires manual remediation. Add a Common
Issues entry for the wirebox package-not-found boot failure. Applied to
both v4-0-0 and v4-0-1-snapshot guides.

Fixes #2627

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…nfig/settings.cfm (#2637)

* docs(web/guides): document reloadPassword must be set via env() in config/settings.cfm

Co-authored-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(web/guides): address Reviewer A/B consensus findings (round 1)

Drop the internal struct-key references from the reloadPassword
explanation. The framework reads/writes `application.$wheels.reloadPassword`,
not `application.wheels.reloadPassword`, but that distinction is an
implementation detail users do not need. Simplify the prose to describe
the user-visible mechanism only (`set(reloadPassword = ...)` in
`config/settings.cfm`).

- web/sites/guides/src/content/docs/v4-0-1-snapshot/upgrading/3x-to-4x.mdx
  (section 4 paragraph + Common issues bullet)
- web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx
  (section 4 paragraph + Common issues bullet)
- CHANGELOG.md ([Unreleased] Documentation entry)

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2645)

* fix(cli): scan seven more 3.x → 4.x breakers in wheels upgrade check

The `wheels upgrade check --to=4.0.0` 3.x → 4.x branch only flagged
the legacy plugins/ directory, the wheels.Test base class, and
application.wirebox references — 3 of the 11 documented breakers
from the canonical upgrade guide. Users could run the check, see a
short list, upgrade, and trip over the unscanned breakers in
production; the tool's silence read as a green light.

Extend the checks array with seven more grep- and directory-based
scans:

- CORS middleware without allowOrigins (deny-all default in 4.0)
- RateLimiter middleware without explicit trustProxy / proxyStrategy
- allowEnvironmentSwitchViaUrl=true (default flipped in production)
- Missing csrfEncryptionKey in config/ (cookies rotate per deploy)
- Legacy `wheels snippets` invocations in Makefile, package.json,
  .github/workflows/*.yml, and top-level *.sh scripts
- Legacy tests/specs/functions/ directory rename
- Vite asset helpers (viteStrictManifest default flip)

The grep runner gains two backward-compatible options to support
these checks: `scanTargets` (mixed list of files + directory roots
for the multi-root snippets scan) and `absent` (warn when the
pattern is NOT found anywhere, used by the csrfEncryptionKey
check). Existing 2.x → 3.x checks and the three pre-existing 3.x →
4.x checks are unchanged.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(web/guides): list all ten 3.x→4.x checks in upgrade command reference

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(cli): address Reviewer A/B consensus findings (round 1)

- Replace Elvis `?:` on `check.extensions` in upgrade-check runner with
  `structKeyExists` ternary (cli/lucli/Module.cfc:3739). The previous
  form crashed Adobe CF whenever a `scanTargets` directory target
  lacked an `extensions` key and the parent check had no top-level
  `extensions` either — a live crash path on every Adobe CF run of
  `wheels upgrade check` reaching the `wheels snippets` check.
- Strengthen RateLimiter check description and fix text so users
  recognise the scan is advisory and fires on every instantiation
  regardless of current config (cli/lucli/Module.cfc:3629).
- Correct the "Each grep scans .cfc and .cfm files" prose in both
  upgrade.mdx pages — the new `wheels snippets` check also scans
  Makefile, package.json, .yml, .yaml, and .sh files.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(cli): document intentional empty pattern in directory check

Round-2 polish from Reviewer A/B exchange: B flagged that
`pattern: ""` in the tests/specs/functions/ directory check is a
silent trap if anyone unifies the directory and grep branches —
`reFindNoCase("", anyString)` matches every line. A conceded the
point and proposed a brief inline comment. Comment-only change;
no behavior diff.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…e new Wheels CLI (#2639)

* fix(cli): deprecate legacy `box wheels upgrade` and point users at the new Wheels CLI

The CommandBox `box wheels upgrade` command (`cli/src/commands/wheels/upgrade.cfc`)
hardcoded its available-versions list at 3.1.0, so a 3.x user invoking it was
silently told "You are already on the latest version" even though 4.0 had
shipped. The post-upgrade-recommendations URL also pointed at the pre-4.0
upgrade guide.

Print a deprecation banner directing users at the new Wheels CLI
(`brew install wheels-dev/wheels/wheels` → `wheels upgrade check` →
`brew upgrade wheels`) and short-circuit `run()` before the stale machinery
runs. The unreachable post-upgrade-recommendations URL is updated to the
canonical v4.0 guide for consistency. The legacy CommandBox `wheels-cli`
module remains scheduled for removal in v5.0 per the 4.0 CHANGELOG.

Fixes #2634

Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>

* docs(web/guides): note legacy CommandBox wheels-cli deprecation in 3x-to-4x upgrade guide

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(cli): address Reviewer A/B consensus findings (round 1)

- Remove `(issue #2634)` references from upgrade.cfc component doc
  block and run() inline comment; per CLAUDE.md, issue refs belong
  in the commit / PR description, not in code that outlives the
  ticket.
- Replace bare `var repoRoot` / `var upgradePath` in
  LegacyUpgradeDeprecationSpec.cfc's describe() body with a shared
  `ctx` struct read by the nested `it()` closures; CLAUDE.md
  "Closure gotcha" warns inner closures cannot reliably read
  outer-closure local vars on Adobe CF.
- Guard each `fileRead(ctx.upgradePath)` call with a `fileExists`
  check that surfaces a clean WheelsTest `fail()` instead of
  letting `fileRead` throw if the CLI source is absent.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(cli): address Reviewer A/B consensus findings (round 2)

- Add `brew upgrade wheels` as a third line to the legacy CommandBox
  `wheels-cli` deprecation code block in
  `web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx`
  and the snapshot copy at
  `web/sites/guides/src/content/docs/v4-0-1-snapshot/upgrading/3x-to-4x.mdx`.
  The deprecation banner in `upgrade.cfc` already lists three steps
  (install, check, upgrade); the docs previously stopped after step
  2, leaving a user who got a positive `wheels upgrade check` result
  without guidance on how to actually perform the upgrade.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(cli): address Reviewer A/B consensus findings (round 3)

- Remove `Issue #2634:` prefix from the component docblock of
  vendor/wheels/tests/specs/cli/LegacyUpgradeDeprecationSpec.cfc.
  CLAUDE.md "Doing tasks" rule: don't reference the current task or
  issue number in code comments. Round 1 removed the same violation
  from upgrade.cfc itself but missed the spec's docblock; this closes
  that gap. The surrounding regression rationale is self-contained.
- Improve the `firstAvailableCall > 0` assertion failure message so a
  future contributor who removes the dead-code block under `return;`
  in upgrade.cfc gets a "delete this spec too" hint rather than a
  message that looks like the original regression has returned. The
  boolean check itself is unchanged.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
bpamiri and others added 26 commits May 16, 2026 07:30
…2736)

* feat(web/blog,model): beyond findAll post + whereIn empty-array fix

Fourth post in the post-GA series after the rate-limiter, packages, and
stdio-MCP articles. Walks scopes, enums, and the chainable query builder
as three pieces of one design — all three return deferred-query proxies
that materialise into the same finder-argument struct on a terminal
call.

Drafting surfaced a real framework bug in QueryBuilder.whereIn() /
whereNotIn() with empty arrays:

- Empty input produced literal SQL "property IN ()", malformed in every
  supported engine (Postgres / MySQL / SQL Server / SQLite / H2),
  surfacing as a generic JDBC syntax error with no pointer back to the
  call site that built the empty collection.
- whereIn now short-circuits to "1 = 0" (no rows match — SQL-spec
  answer for "match any of these zero values"), and whereNotIn to
  "1 = 1" (every row matches). Matches behaviour Rails, Sequel, Django,
  and Laravel Eloquent all converged on.
- Four new specs in queryBuilderSpec.cfc cover empty-array, empty-list,
  composition with other clauses, and the whereNotIn mirror case.
- Both copies of the query-builder guide (v4-0-0 and v4-0-1-snapshot)
  updated to document the short-circuit in the methods reference table.

Article also flags three related rough edges left for follow-up:
no .toSql() debugging helper, no defaultScope() / unscoped(), and no
guard against enum value-name collisions with model method names.

* fix(model): address Reviewer A/B consensus findings (round 1)

- Collapse 5-line and 4-line comment blocks in
  vendor/wheels/model/query/QueryBuilder.cfc whereIn/whereNotIn
  empty-input branches to single lines (CLAUDE.md "Never write
  multi-line comment blocks").
- Add symmetric whereNotIn("id", "") empty-list spec in
  vendor/wheels/tests/specs/model/queryBuilderSpec.cfc to match the
  existing whereIn empty-list coverage.

Reviewer A/B converged on these two changes across three rounds; the
commit-type nit (feat vs fix) was a false positive (the PR ships a
~2,700-word blog post as its primary deliverable, so feat is correct
at the PR level — this address-review commit itself is a fix).

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(model): short-circuit whereIn(empty) at terminal, not via raw SQL literal

The earlier approach (7e9a283 / f1a300e) appended literal `1 = 0` and
`1 = 1` clauses to variables.whereClauses for empty input. That broke
at runtime: Wheels' WHERE-clause parser in vendor/wheels/model/sql.cfc
runs a property-extraction regex over every clause it sees, including
ones with no actual column. For `1 = 0`, the parser reads `1` as the
property name, fails to find it in propertyStruct, and throws
Wheels.ColumnNotFound. All four new whereIn empty-array specs failed
on Lucee 7 + SQLite for this reason — same root cause for the bot's
f1a300e push, which kept the literal approach.

The fix that works alongside the parser instead of around it: set an
$alwaysEmpty flag on the builder for empty whereIn, and check it at
each terminal method (count, findAll, findOne, first, exists,
updateAll, deleteAll, findEach, findInBatches). The flag short-circuits
to the appropriate zero-row sentinel (0, false, QueryNew("")) before
the WHERE parser sees anything. whereNotIn(empty) becomes a no-op:
appending no clause means the chain proceeds normally and matches
every row, which is the right semantic ("exclude none" = "match all").

User-facing behaviour matches what Rails, Sequel, Django, and Laravel
Eloquent all converged on (empty IN matches no rows; empty NOT IN
matches every row). Implementation differs from those frameworks
because Wheels' WHERE parser has a stricter shape requirement.

Also includes:
- Reviewer A/B round-1/2 consensus items beyond the runtime fix:
  multi-line comment blocks collapsed (already in f1a300e), missing
  whereNotIn empty-list spec added (already in f1a300e), whereNotIn
  composition test added (B round 2 missed-issue).
- Article, CHANGELOG, social post, and both copies of the
  query-builder guide updated to describe the corrected design.

* fix(model): shape empty whereIn result like a normal zero-row findAll

Reviewer A round-3 finding on ac32317: findAll() short-circuit returned
QueryNew("") — a query with recordCount=0 but no columns. A normal
findAll() returning zero rows still carries the model's full column set
(read.cfc:220 constructs `QueryNew(local.columns)` in the empty-result
path). Callers that introspect result.columnList — for example, an API
serializer that exposes the field shape, or a debug tool — would see
inconsistent results depending on whether the empty came from a real
no-match query or a short-circuited whereIn.

Fix: QueryNew(variables.modelReference.$classData().columnList) instead.
$classData() returns variables.wheels.class which has the authoritative
columnList the framework already maintains for the model. No DB call,
same shape as a normal no-match findAll.

Also adds three specs to address the "only count() is exercised"
coverage gap noted in the same review:

- findAll() with empty whereIn returns recordcount=0 and a non-empty
  columnList (verifies the shape fix).
- first() returns false.
- exists() returns false.

These cover the four most commonly-called terminals — count, findAll,
first, exists. The remaining four (findOne is aliased to first;
updateAll, deleteAll, findEach, findInBatches are two-line wrappers
that follow the same pattern) are mechanically identical to the
covered cases.

* fix(model): collapse test comment + document findAll short-circuit caveat

Two non-blocking findings from Reviewer A round 4:

- Test comment block in queryBuilderSpec.cfc (3 lines) collapsed to one
  line per CLAUDE.md's one-line-max rule.
- Code note added to the findAll() short-circuit explaining that any
  chained .select() or .include() is intentionally ignored on the
  $alwaysEmpty path. Projection and eager-load are moot when the
  result has zero rows, and computing them from $classData would
  duplicate read.cfc's $createSQLFieldList logic. The trade-off is
  worth flagging in source for the next maintainer.

DCO sign-off finding noted but skipped: the DCO check does not appear
in this PR's required status checks (verified via get_check_runs on
multiple SHAs), and PRs #2734 and #2735 both merged cleanly without
Signed-off-by trailers. The CONTRIBUTING.md statement and operational
reality on this repo disagree — that's a maintainer-side question, not
a content fix for this PR.

* style(model): shorten long inline comments in QueryBuilder

Reviewer A/B round-5 convergence: three inline comments in
QueryBuilder.cfc (lines 36, 128, 336) were technically one line but
ran 133, 232, and 282 characters — outside the spirit of CLAUDE.md's
"one short comment line max." Collapsed to short single lines (and a
two-line form for the findAll() short-circuit comment that preserves
both the shape rationale and the chained-select() caveat).

No behaviour change.

* docs: update stale "six new specs" count to nine across post/CHANGELOG/social

Reviewer B round-1 nit on fee8621: CHANGELOG, blog post, and social
skeleton all said "six new specs" but the actual file has nine (six
count()-based, plus findAll, first, and exists). The "six" figure was
correct mid-PR and got stale as round-3 added the three additional
terminal specs. Numbers now match the file.

No code or test changes.

* docs(blog): move unpublished series posts to drafts folder

CI promotes any file in web/content/blog/posts/ to the live blog on the
next deploy, so unpublished drafts shouldn't live there. Move the three
queued posts into docs/releases/blog-drafts/ where they wait until a
human moves them back into web/content/blog/posts/ on publication day.

Reschedule the every-other-day cadence the user wants, starting the day
after the rate-limited-API post (published 2026-05-15):

- Anatomy of a Wheels Package: 2026-05-22 -> 2026-05-17 (Sunday)
- Wheels + Claude (stdio MCP):  2026-05-29 -> 2026-05-19 (Tuesday)
- Beyond findAll:               2026-06-05 -> 2026-05-21 (Thursday)

Also updated:

- Each article's teaser line ("Coming next week") replaced with the
  actual weekday matching the new cadence.
- Companion social-post skeletons in docs/releases/blog-skeletons/
  updated to point at the new draft paths and the new dates.
- New docs/releases/blog-drafts/README.md explains the promotion
  workflow (move draft -> web/content/blog/posts/ -> CI publishes).

* test(model): close whereIn empty-array terminal + select/include gaps

Reviewer A and B have both carried two non-blocking coverage gaps
forward across multiple rounds, and B's most recent comment landed
malformed (literal \n escapes) so the convergence marker didn't parse
cleanly. The substance B keeps surfacing is real: four $alwaysEmpty
terminals were unspecced, and the documented select()/include()
silent-ignore on the short-circuit path had no spec lock either.

Five new specs in queryBuilderSpec.cfc close both:

- updateAll() returns 0 and touches no rows
- deleteAll() returns 0 and removes no rows
- findEach() never invokes its callback
- findInBatches() never invokes its callback
- findAll() ignores chained select() — the empty-result columnList is
  the full model column list, not a projection of the chained select.
  Locks in the trade-off documented in QueryBuilder.cfc lines 336-337.

Total spec count for the whereIn empty-array fix goes from 9 to 14;
CHANGELOG, article, and social skeleton updated to reflect the new
count and the broader terminal coverage. No framework code changed.

* test(model): start whereIn-empty + select chain at QueryBuilder entry point

The model's onMissingMethod() only enters the QueryBuilder for a small
allowlist of starting methods (where, orWhere, whereNull, whereNotNull,
whereBetween, whereIn, whereNotIn, orderBy, limit, offset). select() is
not in that list — it's a builder method but not an entry point. So
`model("author").select("id").whereIn(...)` errors before whereIn ever
sets the $alwaysEmpty flag, and the test fails before its assertions
run. Lucee 7 + SQLite caught this on the prior push.

Swap the chain order to start with whereIn (an entry point), then
chain select() on the returned builder. The test still verifies the
same documented trade-off: the $alwaysEmpty short-circuit ignores any
chained select(), returning the model's full columnList rather than a
projection.

* test(model): collapse comment + tighten columnList assertion in select-ignore spec

Two Reviewer A nits on 7e9b2e7:

- Multi-line comment block (6 lines) collapsed to one short line per
  CLAUDE.md's one-line-max rule.
- Len(result.columnList) -> ListLen(result.columnList) so the assertion
  counts columns rather than characters. Both forms passed for the
  author model in practice, but ListLen is the semantically correct
  check ("more than one column" vs "string longer than one char").

No behaviour change.

* test(model): use ListLen on the round-3 columnList assertion too

Reviewer A round-9 nit: spec 7 (the findAll() shape spec from round 3)
still used Len(result.columnList).toBeGT(0), the same Len-vs-ListLen
issue round 8 fixed on spec 14. Aligning the assertion across both
specs — ListLen counts columns, which is what the assertion is
semantically about.

The round-3 form was technically correct on the author model (columnList
is always non-empty when the model loads) but ListLen reads more clearly.

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…#2731)

* feat(view): add viewStyle preset to paginationNav and pageNumberLinks for Bootstrap markup

The viewStyle argument accepts "plain" (default), "bootstrap5", "bootstrap4", or
"tailwind". Bootstrap presets emit the canonical
<nav><ul class="pagination"><li class="page-item active" aria-current="page">
<span class="page-link">N</span></li> structure — active class on the <li>
wrapper, <span> for the current page — so Bootstrap-styled apps no longer need
a Replace() regex hack to move the active class off the anchor.

Default remains "plain" to preserve today's output byte-for-byte.

Fixes #2718

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs: document viewStyle preset in CLAUDE.md pagination quick reference

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(view): address Reviewer A/B consensus findings (round 1)

- vendor/wheels/view/pagination.cfc: encode arguments.text via
  EncodeForHTML when encode=true in the Bootstrap/Tailwind disabled
  branches of $renderPaginationNavLink. Previously local.encode was
  resolved but ignored, causing custom HTML-entity nav text (e.g.
  &laquo;) to render literally in the preset path while being
  decoded in the plain path.
- vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc: replace
  the weak Tailwind pageNumberLinks assertion (which passed even if
  Bootstrap markup was accidentally emitted) with Tailwind-specific
  class assertions (pagination-current, pagination-link), plus
  paginationNav coverage for viewStyle="bootstrap4" (active class on
  <li>, no aria-current) and viewStyle="tailwind" (flat
  <nav class="pagination"> with no <ul>, pagination-disabled spans
  on first page), and a showInfo=true positioning spec for
  Bootstrap 5.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(view): throw Wheels.InvalidViewStyle on unknown viewStyle (round 2)

Reviewer A flagged in round 2 that the three internal $renderPagination*
helpers silently emit structurally broken HTML when handed an unknown
viewStyle value (e.g. a typo like "boostrap5"). The call-site guard
`local.useViewStyle = Len(arguments.viewStyle) && arguments.viewStyle
!= "plain"` already filters out the empty/plain case, so reaching the
default branch is by definition a developer typo. Replace the silent
fallbacks with a Wheels.InvalidViewStyle throw so the failure is loud
and immediate at development time instead of surfacing as garbled output.

Specs cover both the pageNumberLinks() and paginationNav() public entry
points, asserting the typed throw using the existing toThrow matcher
pattern from assetsSpec/viteSpec.

Round-2 carry-over per Reviewer A; round-1 address-review intentionally
skipped this per Reviewer B's "nice-to-have" classification, but
Reviewer A held it open as still required.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: list bootstrap4 and tailwind under paginationNav too

Reviewer A's round-2 docs nit: the CLAUDE.md "Pagination View Helpers"
quick reference showed paginationNav with bootstrap5 only, while
bootstrap4 and tailwind appeared only under pageNumberLinks. Both
presets are equally valid on both helpers, so add the paginationNav
companion lines for the two missing presets.

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: extend viewStyle ignore list with prepend/append/anchorDivider

Reviewer A round-6 docs gap: the CLAUDE.md "Non-plain presets ignore..."
list was written before PR #2730 added prepend/append/anchorDivider
args. Those args also land in local.skipArgs and are NOT forwarded into
$renderPaginationNav(), so the preset path silently drops them. Add the
three names to the explicit ignore list so anyone reading the docs
sequentially sees the full picture.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…ppress (#2733)

* fix(view): paginationNav anchor modes default to window-aware auto-suppress

Widens `showFirst` / `showLast` / `showPrevious` / `showNext` on
`paginationNav()` from `boolean` to `any`, accepting the tri-state
strings `"auto"` / `"always"` / `"never"` with backwards-compatible
boolean coercion (`true` -> `"always"`, `false` -> `"never"`). Defaults
flip from `true` to `"auto"`. Under `"auto"` the first/last anchors only
render when the visible page-number window does not already reach the
boundary, restoring the legacy 3.x `paginationLinks(alwaysShowAnchors=false)`
semantics that a like-for-like swap to `paginationNav()` previously lost.
Adds a `windowSize` arg on `paginationNav()` so the auto-mode predicates
stay coherent with `pageNumberLinks()`'s window.

Fixes #2716

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(web/guides): document paginationNav anchor display modes and alwaysShowAnchors parity

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(view): address Reviewer A/B consensus findings (round 1)

Addresses the converged-changes consensus from Reviewer A and Reviewer B
on PR #2733:

- `vendor/wheels/view/pagination.cfc`: under `"auto"`, `showPrevious` and
  `showNext` now always delegate to `previousPageLink()` /
  `nextPageLink()`, preserving the legacy disabled-span boundary
  indicator that the original PR silently dropped at page 1 / last page.
  `"never"` remains the explicit hide-everything choice. Option (b) from
  the reviewers' converged guidance.
- `vendor/wheels/view/pagination.cfc`: `windowSize` added to
  `local.skipArgs` so it no longer leaks into `firstPageLink()`,
  `lastPageLink()`, `previousPageLink()`, `nextPageLink()` (none of
  which declare it). It is passed explicitly to `pageNumberLinks()` to
  keep the windowed page-number coverage coherent with the auto-mode
  predicates.
- `vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc`: six new
  `it()` blocks under `paginationNav anchor display modes` covering
  Previous on page 1 and Next on last page in auto / never modes, plus
  the corresponding non-boundary paths.
- `CHANGELOG.md`, `CLAUDE.md`,
  `web/sites/guides/src/content/docs/v4-0-1-snapshot/upgrading/3x-to-4x.mdx`:
  prose updated to describe the prev/next auto delegation and the
  `"never"` opt-out, alongside the existing first/last auto-suppress
  documentation.

Local test run: `directory=wheels.tests.specs.view` against the Lucee 7
+ SQLite server returns 529 pass, 0 fail, 0 error (up from 523 — six
new tests added).

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore(web): refresh visual baseline(s) (blog)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml on branch
fix/bot-2716-paginationnav-default-anchor-display-ux-differs-fr.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering; re-run the failing visual-regression job to flip the
check green.

* chore(web): refresh visual baseline(s) (blog)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml on branch
fix/bot-2716-paginationnav-default-anchor-display-ux-differs-fr.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering; re-run the failing visual-regression job to flip the
check green.

* docs: address Reviewer A nits on CLAUDE.md pagination docs

- Remove `windowSize` from the "Accepted pass-through keys" list — it
  is a first-class `paginationNav()` argument (in `skipArgs`), not a
  sub-helper passthrough. Document its first-class status explicitly
  so future contributors form the right mental model about what
  "pass-through" means in the validation context.
- Annotate the Bootstrap 5 like-for-like swap example with a one-line
  note that callers wanting 3.x `alwaysShowAnchors=true` behaviour
  should pass `showFirst=\"always\"` / `showLast=\"always\"`. The
  upgrade guide already covers this; the CLAUDE.md quick-reference
  example did not.

Signed-off-by: Peter Amiri <petera@pai.com>

* fix(view): address Reviewer A/B consensus findings (round 3)

- Move $paginationAnchorMode validation calls for showFirst/showLast/
  showPrevious/showNext above the totalPages early-return in
  paginationNav() so invalid mode strings throw on single-page or
  empty result sets too. Matches the unknown-arg validation's stated
  rationale a few lines above; eliminates the silent-success on
  totalPages <= 1 that B flagged.
- Strengthen the windowSize propagation spec in paginationHelpersSpec
  with expect(result).toInclude(">8<") so the test fails if windowSize
  is silently dropped from the pageNumberLinks() call. The existing
  notToInclude("First") alone could not catch a regression there
  because the First-hide predicate reads arguments.windowSize directly.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore(web): refresh visual baseline(s) (blog)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml on branch
fix/bot-2716-paginationnav-default-anchor-display-ux-differs-fr.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering; re-run the failing visual-regression job to flip the
check green.

* fix(view): address Reviewer A/B consensus findings (round 4)

Forward `windowSize` to `pageNumberLinks()` on the `paginationNav()`
viewStyle path so the auto-mode predicate and the rendered window
are evaluated against the same value.

- `vendor/wheels/view/pagination.cfc`: add `numeric windowSize` to
  `$renderPaginationNav()` signature, pass `windowSize` at the
  call site, and copy it into `local.pageArgs` before invoking
  `pageNumberLinks()`.
- `vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc`: add a
  regression-guard spec under "paginationNav with viewStyle presets"
  asserting that `paginationNav(viewStyle="bootstrap5", windowSize=4)`
  with `currentPage=5, totalRecords=100, perPage=10` includes page 8
  in the output.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore(web): refresh visual baseline(s) (blog)

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml on branch fix/bot-2716-paginationnav-default-anchor-display-ux-differs-fr.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering; re-run the failing visual-regression job to flip the
check green.

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <petera@pai.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…S middleware is registered (#2728)

* fix(middleware): short-circuit OPTIONS preflight in dispatch when CORS middleware is registered

The new middleware pipeline ran AFTER route matching, so an OPTIONS
preflight against a path that only declared POST/PUT/PATCH/DELETE
404'd in `$findMatchingRoute()` before `wheels.middleware.Cors`'s
preflight branch could fire. The legacy `set(allowCorsRequests=true)`
path aborted OPTIONS in `EventMethods.cfc` before dispatch, so the
new middleware was strictly less capable than the 3.x setting it
replaced.

`Dispatch.$request()` now checks the verb up front and, if it is
OPTIONS and the global pipeline contains a `wheels.middleware.Cors`
instance, runs the pipeline against a no-op core handler so the CORS
middleware can set headers and return without touching the route
table. Behavior for OPTIONS without CORS middleware (still 404s) and
for non-OPTIONS verbs (routed normally) is unchanged.

Fixes #2703.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(web/guides): note that OPTIONS preflight short-circuit requires global Cors registration

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(middleware): address Reviewer A/B consensus findings (round 1)

- vendor/wheels/middleware/Cors.cfc: read request_method from
  arguments.request.cgi first (fall back to engine CGI scope) so the
  middleware respects the per-request context the pipeline passes in.
  Mirrors the RateLimiter pattern; required because a bare `request`
  reference inside a function resolves to the engine REQUEST scope,
  not the function argument. No production behavior change — engine
  CGI remains the fallback.
- vendor/wheels/tests/specs/middleware/CorsSpec.cfc: add a unit test
  for the OPTIONS short-circuit branch that was previously dead from
  a unit-test perspective.
- vendor/wheels/tests/specs/middleware/CorsPreflightDispatchSpec.cfc:
  switch _savedStaticRoutes from StructCopy (shallow) to Duplicate
  (deep) to match _savedRoutes; clarify in the first spec why the
  empty-string assertion is satisfied by Dispatch's no-op handler
  rather than Cors's own OPTIONS branch.
- vendor/wheels/Dispatch.cfc: document the intent of the empty catch
  block around $getRequestMethod() (fail-closed: skip the
  short-circuit and let normal routing proceed).
- web/sites/guides/src/content/docs/v4-0-0/digging-deeper/cors.mdx:
  mirror the v4-0-1-snapshot Aside warning that the preflight
  short-circuit requires global Cors registration; route-scoped Cors
  does not benefit because route matching runs first.

Middleware suite: 3557 pass / 0 fail / 0 error (Lucee 7 + SQLite),
including the new CorsSpec test and existing CorsPreflightDispatchSpec.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* chore(web): refresh visual baseline(s) (blog)

Re-commits the auto-generated blog visual baseline PNG that was first
produced by the refresh-visual-baselines.yml workflow run on this PR
branch. The original commit body exceeded the 100-char limit enforced
by commitlint (the branch name expanded inline), failing the Validate
Commit Messages check; this re-commit carries the same binary content
with a wrapped message.

Run the visual-baselines workflow when an intentional content/layout
change makes the visual-regression check fail. The new PNG(s) under
web/tests/visual-baselines/ are now the expected rendering; re-run the
failing visual-regression job to flip the check green.

Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(middleware): address Reviewer A round-2 nits

- Cors.cfc: normalise local.requestMethod with UCase() before comparing
  to "OPTIONS", matching the gating site in Dispatch.cfc and removing
  the case-sensitivity inconsistency.
- Cors.cfc: correct the inaccurate comment about bare `request` resolving
  to the engine REQUEST scope. In CFML the arguments scope has higher
  lookup priority than named scopes (REQUEST, CGI), so bare `request`
  inside handle() resolves to arguments.request. The actual reason for
  the arguments.request.cgi-first lookup is that the engine CGI scope is
  read-only on Lucee 7, blocking unit tests from injecting OPTIONS via
  cgi.request_method.
- Dispatch.cfc: drop the unused `method = local.preflightMethod` field
  from preflightContext. Cors.handle() reads the verb from
  arguments.request.cgi.request_method, not arguments.request.method,
  so the field is dead. Replace with a brief comment noting why the
  short-circuit context omits it (only Cors runs from this code path).

No behaviour change. Existing CorsSpec and CorsPreflightDispatchSpec
coverage continues to gate the OPTIONS short-circuit.

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(middleware): clarify preflight-context comment in Dispatch.cfc

Reviewer A round-3 noted that the comment "only Cors is run from this
short-circuit" is technically imprecise — `$middlewarePipeline.run()`
still executes any middleware registered before Cors. Cors then
short-circuits without calling next, so middleware registered after it
does not run. Rewrite the comment to describe the actual pipeline
behaviour. No code change.

Signed-off-by: Peter Amiri <peter@alurium.com>

* chore(web): refresh visual baseline(s) (blog)

Re-commits the auto-generated blog visual baseline PNG produced by the
refresh-visual-baselines.yml workflow run on this PR branch. The bot's
default commit body expanded the branch name inline, producing a line
that exceeded the 100-char limit enforced by commitlint; this re-commit
carries the same binary content with a wrapped message body.

Run the visual-baselines workflow when an intentional content/layout
change makes the visual-regression check fail. The new PNG(s) under
web/tests/visual-baselines/ are now the expected rendering; re-run the
failing visual-regression job to flip the check green.

Signed-off-by: Peter Amiri <peter@alurium.com>

* chore(web): refresh visual baseline(s) (blog)

Re-commits the auto-generated blog visual baseline PNG produced by the
refresh-visual-baselines.yml workflow on this PR branch. The bot's
default commit body expanded the branch name inline, producing a line
that exceeded the 100-char commitlint limit; this re-commit carries the
same binary content with a wrapped message body.

Triggered after develop merged PR #2735, which added a new blog post
(wheels + claude stdio MCP setup) that shifted the blog index render.

Signed-off-by: Peter Amiri <peter@alurium.com>

* chore(web): refresh visual baseline(s) (blog)

Re-commits the auto-generated blog visual baseline PNG produced by the
refresh-visual-baselines.yml workflow on this PR branch. The bot's
default commit body expanded the branch name inline, producing a line
that exceeded the 100-char commitlint limit; this re-commit carries the
same binary content with a wrapped message body.

Triggered after develop merged PR #2736, which added a new blog post
(beyond findAll) that shifted the blog index render. Same workflow
body-length bug previously hit on #2734 and #2735 baseline refreshes
in this PR.

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(middleware): address Reviewer A/B consensus findings (round 2)

Address Reviewer A's design note (echoed in Reviewer B's converged-changes
verdict) about the dispatch preflight short-circuit's subclassing constraint.

`Dispatch.$hasPreflightCapableMiddleware()` detects preflight-capable
middleware with `IsInstanceOf(mw, "wheels.middleware.Cors")`, so the
short-circuit fires for the canonical class and any subclass that extends
it -- but a custom CORS middleware that implements `MiddlewareInterface`
directly without extending `wheels.middleware.Cors` will not trigger the
short-circuit. Existing `<Aside>` only covered the global-vs-scoped
limitation; this commit extends it with the subclassing constraint and
the recommended workaround (extend `wheels.middleware.Cors`).

Docs updated in both v4-0-0 and v4-0-1-snapshot guides; no code change
because the behaviour itself is correct and `IsInstanceOf` is the right
detection primitive for a stdlib-recognised CORS contract.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…char limit (#2737)

The auto-generated commit body inlined the branch name on the same line as
the workflow path, producing lines >100 chars for normal-length branch
names (e.g. fix/bot-2703-wheels-middleware-cors-cannot-short-circuit-option
ran 121 chars) and failing commitlint's body-max-line-length rule.

Split "on branch <BRANCH>." onto its own line so the longest body line now
caps at "on branch " + branch + ".", leaving ~89 chars of headroom for the
branch name itself.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ti-patterns #11-14 (#2740)

CLAUDE.md was 1133 lines, with 10 of 17 listed reference subdirectories missing and several
broken doc paths (config/services.cfm, docs/src/...). Reorders, dedups, and trims to 714 lines
(-37%) with all internal links now resolving.

Top-of-file restructured for high-leverage content first:
- Code Map: where framework/demo/CLI live and how they relate
- Before-Done checklist: which test suite to run for which change type
- Cross-Engine Invariants: promoted from buried Docker / Browser-Testing locations
- Anti-Patterns extended Top 10 -> Top 14, all new entries sourced from recent PRs:
  - #11 CFML reserved scopes shadow parameters (#2591)
  - #12 Empty array in whereIn / whereNotIn (#2736)
  - #13 Comma-list config != single-value HTTP header (#2725)
  - #14 Strip CFML comments before source-scanning (#2595)

Extracted sections, loaded only when relevant:
- .ai/wheels/deploy.md (92 lines) - wheels deploy Kamal port reference
- .ai/wheels/wheels-bot.md (34 lines) - bot architecture
- .ai/wheels/testing/browser-testing.md (68 lines) - browser DSL

Other dedups: t.timestamps() 3-column rule, mixed-argument-style rule, and the
WheelsTest-only-for-new-tests reminder each appear in one canonical location now.
Reference Docs list at bottom lists only verified-to-exist files.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ockingSpec skips (#2746)

* fix(model): override $supportsAdvisoryLocks=false on CockroachDB so lockingSpec skips

CockroachDBModel extends PostgreSQLModel, which reports
$supportsAdvisoryLocks()=true since pg_advisory_lock is native there.
CockroachDB intentionally omits the primitive — its adapter throws
"CockroachDB does not support advisory locks." from $acquireAdvisoryLock —
but it never overrode the capability flag, so the four lockingSpec
withAdvisoryLock tests bypassed the beforeEach skip-guard added in
#2670 and reported as errors on every engine (lucee6, lucee7, boxlang).

PR #2670's CHANGELOG entry already named CockroachDB in the "skips on"
list, but the override itself was missed. This adds the one-method
override, mirroring how MSSQL/SQLite/etc. each carry their own
$supportsAdvisoryLocks override over Base.cfc's default-false.

Fixes #2743

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(model): trim redundant forUpdate hint from CockroachDB $supportsAdvisoryLocks docblock

Reviewer A nit on #2746: the final sentence duplicates guidance already on
the adjacent $acquireAdvisoryLock / $releaseAdvisoryLock docblocks. Keep
the comment focused on the WHY (inheritance/capability-flag subtlety).

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
…ssertion (#2748)

* fix(migrator): carve out MySQL in addColumnOptionsSpec text-default assertion

Closes #2742. Follow-up to #2661/#2669.

`addColumnOptionsSpec :: text with a real default (non-empty) still emits
DEFAULT` failed on every MySQL leg of the compat matrix (lucee6/mysql,
lucee7/mysql, boxlang/mysql) with the assertion message
`The needle [DEFAULT] was not found in [ NULL]`. The MySQL adapter's
`optionsIncludeDefault` intentionally returns false for `text`,
`mediumtext`, `longtext`, and `float`, so the inherited Abstract
`addColumnOptions` short-circuits the entire DEFAULT clause for those
types — emitting only the `NULL`/`NOT NULL` suffix. The pre-8.0.13 MySQL
constraint that TEXT/BLOB columns reject DEFAULT is what motivates the
suppression, and the framework targets the broadest supported MySQL
surface rather than emitting DDL that fails on older servers.

The spec now mirrors the existing `isPostgresFamily` adapter-aware
pattern: a new `variables.isMySQLFamily` flag in `beforeAll` carves out
the MySQL branch, asserting `notToInclude("DEFAULT")` while every other
Abstract-based adapter continues to assert `toInclude("DEFAULT")` plus
`toInclude("'long body'")`. Header doc-comment updated to document the
MySQL divergence alongside the PG/CockroachDB one.

`MySQLMigrator.optionsIncludeDefault`'s one-line doc-comment was
expanded to explain WHY (legacy MySQL TEXT/BLOB constraint), what the
user-visible behavior is (non-empty default silently suppressed), and to
reference the spec contract — keeping the suppression list and the test
aligned.

Pure test + doc-comment change. No production behavior delta. Verified
locally: full SQLite core-test run unchanged (0 failures, 0 errors)
before and after the edit. CI matrix will validate the MySQL legs.

Signed-off-by: wheels-bot[bot] <283220952+wheels-bot[bot]@users.noreply.github.com>

* docs: document MySQL TEXT/FLOAT DEFAULT suppression in cross-engine-compat guide

MySQLMigrator.optionsIncludeDefault returns false for text-family and float
columns, causing addColumnOptions to silently drop the DEFAULT clause — even
for non-empty defaults. Mirrors the existing isPostgresFamily note with an
isMySQLFamily carve-out example. Surfaced by #2742 / PR #2748.

Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* fix(migrator): address Reviewer A/B consensus findings (round 1)

- Rename the `it()` description at addColumnOptionsSpec.cfc:102 from
  "text with a real default (non-empty) still emits DEFAULT" to
  "text with a real default (non-empty): DEFAULT clause is adapter-dependent".
  The old name contradicted the MySQL branch's `notToInclude("DEFAULT")`
  assertion. The neutral name surfaces the adapter-dependence in CI failure
  reports.
- CHANGELOG.md: append `/#2669` to the trailer of the Fixed entry. The PR
  description and commit body both cite "#2661/#2669", but the CHANGELOG
  trailer dropped #2669 — which is the PR that introduced the
  `isPostgresFamily` carve-out this change explicitly mirrors.

Pure text changes: no executable test logic touched, no production code
delta. Test runner not available in the address-review CI environment
(no `wheels` CLI installed); `test-local.sh` portability bug aside, neither
edit can regress test execution.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: wheels-bot[bot] <283220952+wheels-bot[bot]@users.noreply.github.com>
Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: wheels-bot[bot] <283220952+wheels-bot[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…with-VALUES error (#2749)

* fix(model): Oracle insertAll uses INSERT ALL form to avoid RETURNING-with-VALUES error

Bulk insert SQL was always emitted as the SQL-standard multi-row table
value constructor — `INSERT INTO t (cols) VALUES (?,?), (?,?), ...` —
which Oracle 23 rejects in combination with the JDBC driver's implicit
`RETURN_GENERATED_KEYS` handling ("ORA: returning clause is not allowed
with INSERT and Table Value Constructor", plus a follow-on "ORA: no
statement parsed" for related lifecycle reasons).

Moves bulk-insert SQL building off the model mixin and onto the database
adapter (`$bulkInsertSQL` on Base.cfc, mirroring the existing `$upsertSQL`
pattern). OracleModel overrides it with `INSERT ALL INTO t (cols) VALUES
(...) ... SELECT 1 FROM dual` — Oracle's idiomatic multi-row insert form,
which neither uses the table value constructor nor triggers the RETURNING
expansion. Non-Oracle adapters keep the standard multi-row VALUES shape.

The migrator-rename "Closed statement" error in the same compat-matrix
run is a separate Oracle JDBC lifecycle issue and is left for a follow-up.

Fixes #2745

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs: note Oracle INSERT ALL requirement for bulk insert in cross-engine guide

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…ide (#2747)

* fix(model): skip CockroachDB advisory-lock specs via capability override

CockroachDBModel extends PostgreSQLModel, which reports
`$supportsAdvisoryLocks() = true` because Postgres has pg_advisory_lock.
CockroachDB does not, so its $acquireAdvisoryLock / $releaseAdvisoryLock
throw Wheels.AdvisoryLockNotSupported. Without a capability override the
four standalone advisory-lock specs in `lockingSpec.cfc` skipped past
their beforeEach guard and errored on every Cockroach-backed compat-
matrix run (Lucee 6, Lucee 7, BoxLang). Adding the override lets them
skip cleanly, matching the H2 / SQL Server treatment from #2665.

Fixes #2743

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(model): rework CockroachDB $supportsAdvisoryLocks docblock per review

Reviewer A flagged that the existing wording led with "lets the test
suite skip standalone-lock paths instead of erroring" — framing the
override as a test workaround rather than a correctness fix. Reorder
to lead with the underlying behavior ($acquireAdvisoryLock /
$releaseAdvisoryLock throw Wheels.AdvisoryLockNotSupported), then
explain what consulting the flag enables, then cite the H2 / SQL
Server precedent from #2665.

No functional change.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Peter Amiri <peter@alurium.com>
…e CF 2023 (#2750)

* fix(dispatch): copy arguments to a plain struct in $header() for Adobe CF 2023

Adobe CF 2023 rejects the raw `arguments` scope as `attributeCollection`
on `cfheader`, throwing `Failed to add HTML header` on every request and
recording `0 pass / 0 fail / 0 err` in the compat-matrix Adobe 2023 job
because no test endpoint can boot past application init.

The helper already had the fix on the `statusText` branch (added for
Adobe CF 2025): copy `arguments` into a plain `local.args` struct before
calling `cfheader`. Collapse the two branches so every call hands
`cfheader` a plain struct — no API change, no caller updates.

Closes #2741

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(dispatch): extend plain-struct copy to all attributeCollection sites in Global.cfc

Adobe CF 2023's restriction on the raw `arguments` scope as
`attributeCollection` is engine-level, not specific to `cfheader`. The
initial PR only patched `$header()` (the dispatch-path blocker). This
applies the same plain-struct copy uniformly to all ten remaining
helpers in `Global.cfc` so subsequent test paths that touch `$content`,
`$cache`, `$mail`, `$location`, `$htmlhead`, `$directory`, `$file`,
`$invoke`, `$wddx`, or `$zip` on Adobe CF 2023 do not hit the same
rejection.

Also drop the `g.$header()` call from `headerSpec.cfc`'s `afterEach`:
calling the function under test during cleanup would mask a regression
as an `afterEach` lifecycle error rather than a specific `it`-block
failure. The `cfheader` reset alone is sufficient.

Addresses Reviewer A on #2750.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(global): add semicolons to back-to-back cfheader calls in headerSpec.cfc

Lucee 7's parser fails on two adjacent `cfheader(...)` script calls in
the same block when neither terminates with a semicolon, throwing
`Syntax Error, Invalid Construct` at the closing `})` of the afterEach
arrow function. The error stops the entire vendor/wheels/tests/specs
bundle from compiling, so every test reports HTTP 500.

Semicolons on both cfheader lines disambiguate the statement boundary.
Pattern matches the existing precedent in
vendor/wheels/tests/specs/controller/renderingSpec.cfc:297.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(dispatch): patch direct-struct attributeCollection sites in image and dbinfo

Two helpers in Global.cfc used the CFScript direct-struct form
(`attributeCollection = arguments`, no `##` interpolation) rather than
the string-interpolated form patched in the previous commit. Adobe CF
2023's restriction is on the `arguments` scope shape — not on the
interpolation path — so both forms are at risk.

$dbinfo() is the higher-priority site: it is called from
databaseAdapters/Base.cfc during adapter initialization and from
migrator/Base.cfc on every migration helper. A throw here on Adobe CF
2023 would keep the test matrix red even after the dispatch-path fix.

$dbinfo()'s catch block mutates the arguments scope between its four
cfdbinfo calls, so local.args is rebuilt before each call rather than
copied once.

Addresses Reviewer A's follow-up on #2750.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#2745) (#2753)

* fix(model,migrator): Oracle bulk insert and migrator rename (#2745)

`Model.insertAll()`: Oracle does not allow multi-row
`INSERT ... VALUES (...), (...)` together with the JDBC driver's
auto-RETURNING (triggered by `cfquery result="..."`). Move bulk-insert
SQL generation into the adapter behind a new `$bulkInsertSQL()` hook
(mirrors `$upsertSQL()`); Base keeps the multi-row form, Oracle
returns one single-row INSERT per record so the auto-RETURNING
transform only ever sees a plain `INSERT INTO ... VALUES (...)`.

`Migrator.renameSystemTables()`: Oracle implicitly commits DDL and
closes the JDBC statement, so the surrounding
`transaction action="begin" { ... commit }` then raised
`ORA: Closed statement`. Skip the transaction wrapper on Oracle (the
existing code comment already acknowledged it was a no-op there);
other engines keep their atomic-rollback path.

BoxLang × Oracle was the only engine exercising these paths because
Lucee 6/7 still skip Oracle on the soft-fail track from #2663.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(ai): document Oracle DDL auto-commit transaction-wrapper pattern

Add an `Oracle — DDL Auto-Commit and Transaction Wrapper` section to
the cross-engine compat doc, mirroring the existing Oracle bulk-insert
section. Documents the `Closed statement` failure mode and the
adapter-branch pattern used in `Migrator.renameSystemTables`.

Per Reviewer B's follow-up observation on PR #2753 — closes the
deep-reference gap so future devs adding DDL operations find the
pattern in `.ai/` instead of only in the inline Migrator.cfc comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(ai): clarify MySQL atomicity in DDL transaction-wrapper section

Per Reviewer A's nit on PR #2753: MySQL DDL also implicitly commits
(InnoDB transaction wrapper is a no-op for DDL). The MySQL atomicity
guarantee comes from `RENAME TABLE a, b` being a single multi-pair
statement, not from the transaction. Persistent `.ai/` reference now
reflects that distinction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: align MySQL DDL atomicity wording across CHANGELOG and Migrator.cfc

Reviewer B noted the CHANGELOG entry inherits the same MySQL
imprecision the `.ai/` doc had — "MySQL on InnoDB keep their
atomic-rollback behavior" implies the transaction wrapper rolls
MySQL DDL back, but MySQL DDL also implicitly commits. The
atomicity actually comes from `RENAME TABLE a, b` being a single
multi-pair statement. Update the CHANGELOG entry and the inline
`Migrator.cfc` comment so all three reference points (`.ai/`,
CHANGELOG, code comment) say the same thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

* fix(test): work around BoxLang catch-scope discard in lockingSpec (#2744)

`lockingSpec :: "releases lock even when callback throws an exception"`
failed on BoxLang × MySQL / Postgres / SQLite — the missing leg of #2665
that #2670 intentionally deferred. The test tracked exception propagation
through `local.exceptionThrown = true` inside the `catch` block; on
BoxLang, writes to the `local` scope inside a catch don't survive past
the block (the catch body runs under a nested `local` that gets
discarded on exit), so the post-catch `expect(local.exceptionThrown)`
read the un-touched outer value and failed with
"Expected [false] to be true". Lucee 6 / 7 and Adobe CF 2023 / 2025
share the enclosing function's `local` across the catch, so the
assignment stuck on every other engine and the gap was BoxLang-only.

Switched to the same struct-field pattern TenantResolverSpec already
uses for the equivalent assertion:

  var state = {exceptionThrown = false};
  try { ... } catch (TestException e) { state.exceptionThrown = true; }
  expect(state.exceptionThrown).toBeTrue();

Struct field assignment targets a heap object and survives the scope
transition on every engine. `vendor/wheels/model/locking.cfc` is
unchanged — the lock-release contract was already correct via the
existing `try { callback() } finally { release }`; the lock-release
bug the issue title called out turned out not to exist.

Documented the BoxLang catch-scope quirk in
`.ai/wheels/cross-engine-compatibility.md` so future spec authors
don't re-hit it. Both the `var struct.field` form and the
`var bareName` form are called out as portable; the
`local.X = ...` form is called out as the trap.

Fixes #2744

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(claude): address Reviewer A nits on #2744 (CLAUDE.md invariant + doc + spec)

- Collapse the 8-line in-spec comment to one line. The `.ai/` doc carries
  the full WHY; the spec only needs the pointer. Matches CLAUDE.md's
  "one short line max" rule for inline comments.
- Switch struct literal in `lockingSpec.cfc` from colon (`:`) to equals
  (`=`) syntax to match the prior-art pattern in `TenantResolverSpec`
  (`var result = {threw = false}`) and the WRONG/RIGHT examples in the
  cross-engine doc. Both are valid CFML; consistency wins.
- Add a "Why the bare-`var` form survives" paragraph to the
  `.ai/wheels/cross-engine-compatibility.md` BoxLang catch-scope section
  so future readers don't ask why `var caught; caught = true` survives
  when `local.caught = true` doesn't. Also recommends preferring the
  struct-field form since the bare-var mechanism may not be a stable
  contract across BoxLang releases.
- Add invariant #10 to CLAUDE.md's "Cross-Engine Invariants" checklist.
  CLAUDE.md is the canonical drive-by checklist; spec authors who stop
  there need this rule visible at the surface level. One-liner pattern
  matches the existing 9 invariants.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
…CF 2023 (#2755)

* docs: document attributeCollection + arguments scope gotcha on Adobe CF 2023

Add cross-engine invariant #10 to CLAUDE.md and a matching section to
.ai/wheels/cross-engine-compatibility.md: Adobe CF 2023 rejects the raw
arguments scope passed as attributeCollection (e.g. cfheader), while
Lucee 6/7, BoxLang, and Adobe 2018/2021 accept it. Fix is to copy into
a plain struct first.

Follows fix in vendor/wheels/Global.cfc::$header (#2741).

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs: generalize attributeCollection gotcha to any built-in CFML tag

Reviewer A's round-2 sub-nit and round-3 finding (and Reviewer B's
convergence in the broader round) flagged that CLAUDE.md invariant #10
named only `cfheader`, while the underlying restriction applies to every
built-in CFML tag that accepts `attributeCollection`. PR #2750 has since
merged, patching all 12 affected wrappers in `vendor/wheels/Global.cfc`
uniformly — which is hard evidence the broader rule is correct.

Generalize invariant #10 in CLAUDE.md to "any built-in CFML tag",
enumerate the 12 affected wrappers, and call out the two attribute
forms (`"##arguments##"` and direct-struct). Update
`.ai/wheels/cross-engine-compatibility.md` to match: WRONG block now
shows both forms, the Reference link points to #2750 (the canonical
multi-site fix), and the $dbinfo() per-call rebuild note is preserved
as guidance for helpers that write through `arguments` between tag
invocations.

Docs-only — no code change. Addresses the substantive review feedback
that survived the rebase onto #2750.

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(claude): use single-hash CFML interpolation in invariant #10 example

Reviewer A round 5: invariant #10's inline description of "the
string-interpolated form" used `"##arguments##"`. The double-hash is
CFML's escape for a literal `#` character — a reader copy-pasting it
into CFScript would get the string `#arguments#` rather than evaluating
the arguments scope, the opposite of what the invariant warns against.
The companion .ai/wheels/cross-engine-compatibility.md section at
line 238 already uses single hashes correctly; align CLAUDE.md to match.

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: correct wrapper count from 12 to 13 in attributeCollection invariant

Reviewer B round 7: the enumerated list of affected `Global.cfc`
wrappers has 13 items (cfheader, cfcache, cfcontent, cfmail,
cfdirectory, cffile, cflocation, cfhtmlhead, cfimage, cfdbinfo,
cfinvoke, cfwddx, cfzip) but both CLAUDE.md invariant #10 and
.ai/wheels/cross-engine-compatibility.md said "12". Verified against
develop's `vendor/wheels/Global.cfc` — 13 distinct wrapper functions
forward `arguments` to a `cf*(attributeCollection = ...)` site
(cfmailparam and cfmailpart are correctly excluded — they use the
pre-built `local.i` struct, not the arguments scope).

Pure number fix in two places.

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: extend attributeCollection invariant to Adobe CF 2023 and 2025

Reviewer A round 1 on #2755 caught a real correctness gap: develop's
`Global.cfc::$header()` comment explicitly says "Adobe CF 2023+ rejects
the raw arguments scope" — the `+` is load-bearing — and #2750 patched
all 13 wrappers precisely because the restriction extends past 2023.
The docs as previously written named only "Adobe CF 2023", giving a
developer reading invariant #10 no signal to guard their code on
Adobe CF 2025.

Updates:
- CLAUDE.md invariant #10: "Adobe CF 2023" → "Adobe CF 2023 and 2025"
  throughout (opener, throw clause, "require the plain struct" clause).
- .ai/wheels/cross-engine-compatibility.md: section heading is
  "(Adobe CF 2023/2025)"; intro and Why paragraph name both engines;
  WRONG block comment says "crashes Adobe CF 2023 and 2025".
- RIGHT example in the .ai/ doc now mirrors the WRONG block by showing
  both invocation forms working once `local.args` is a plain struct
  (cfheader interpolated form + cfimage direct form), with a brief
  comment explaining why both are safe at that point — addresses
  Reviewer A's non-blocking nit that the original RIGHT example showed
  only the string-interpolated form while $image() uses the direct form.

Engine matrix is unchanged otherwise: Lucee 6/7 + BoxLang +
Adobe 2018/2021 still accept; Adobe 2023/2025 reject.

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…committed (#2756)

* fix(dispatch): swallow cfheader InvalidHeaderException when response committed

`$header()` is called from `$runOnError` (EventMethods.cfc:113) to set
`Content-Type: application/json` on the error response. On Adobe CF 2023
and 2025, the response buffer can already be committed by the time
onError fires (any partial output from a view that errored mid-render
flushes the buffer at the engine's default threshold). cfheader then
throws `InvalidHeaderException: Failed to add HTML header`, which
replaces the original exception with the cfheader-failure stack — and
every adobe2023/adobe2025 job in the compat matrix returns an HTML
error page whose root cause is the secondary header failure, not the
real bug.

Probe `response.isCommitted()` before calling `cfheader` and return
silently when the buffer has already flushed. Callers that need the
header guaranteed should set it before producing output; inside
onError, swallowing is the right contract because the original
exception is what the operator needs to see. A wrapping try/catch is
kept as defense-in-depth for engines where the probe misbehaves.

The new `$responseCommitted()` helper sits next to `$header()` so
other tag wrappers ($content, $location, $cache, ...) can pick it up
incrementally as we find further onError-cascade failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(dispatch): narrow $header() catch and add $responseCommitted spec

Address review feedback on #2756:

1. The defense-in-depth `catch (any e)` around `cfheader` was swallowing
   every `cfheader` failure, not just the "response committed" race —
   so genuine caller bugs (bad attribute combos, engine bugs) would no
   longer propagate. The catch now re-probes `$responseCommitted()`
   and rethrows when the response is still uncommitted, restoring the
   pre-#2756 error-propagation contract for every path except the
   onError cascade we set out to fix.

2. New spec in `headerSpec.cfc` exercises `$responseCommitted()` and
   asserts the declared `boolean` return type holds on every engine.
   A future API shift (e.g. a BoxLang `PageContext` change) now fails
   here in-process instead of going invisible until a weekly compat run.

3. Added the missing CHANGELOG entry under `[Unreleased] > Fixed`,
   matching the existing prose style for the surrounding entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(test): swallow reset() when response committed in BaseReporter

After the `$header()` defensive fix in dfeaed9 unmasked the original
exception, the adobe2023/adobe2025 compat-matrix logs reveal the real
root cause for the chronic Adobe CF compat-matrix failures: the
vendored TestBox `JSONReporter.runReport()` calls
`BaseReporter.resetHTMLResponse()`, which calls
`getPageContextResponse().reset()` — and on Adobe CF 2023/2025 running
under Undertow, that throws
`IllegalStateException: UT010019: Response already commited` when the
response buffer has flushed (populate.cfm or test infrastructure wrote
output during setup).

The adjacent Lucee-only `resetHTMLHead()` call a few lines up is
already wrapped in `try/catch` for the same defensive reason — extend
the same shape to the bare `reset()` call. If the reset fails the
reporter content still emits, just appended to whatever already
flushed; the structured JSON test result is what runner.cfm consumes
downstream, so the body shape is preserved.

This is the actual blocker for adobe2023/adobe2025 in the weekly
compat matrix — every Adobe job has been returning an HTML error page
whose cfheader cascade masked this upstream reset failure. With both
the dispatch-layer fix (PR #2756) and this reporter fix in place, the
Adobe legs should produce structured JSON test results for the first
time since the matrix was added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(test): broaden BaseReporter catch-comment to match catch scope

Address Reviewer A round-2 nit on #2756. The `catch (any e)` block in
`resetHTMLResponse()` is broader than the previous one-line comment
("Response already committed") suggested — `any` includes any future
engine-specific reason `reset()` might be unavailable, not just the
known Undertow `UT010019` case.

Broaden the comment to reflect actual catch scope (known case + any
other reason `reset()` is unavailable) and document why the catch is
deliberately silent (no `writeDump`): the adjacent `resetHTMLHead()`
`writeDump` is an engine-compat diagnostic — "this Lucee version
doesn't ship the method" — which fires once and is informative. The
`reset()` failure here is a runtime-state condition (response already
flushed) that fires on every successful Adobe CF test run by design,
so a `writeDump` would produce noise on every Adobe leg without
adding signal.

Code behavior unchanged; comment-only fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(test): route runner.cfm header/content calls through defensive helpers

The previous two fixes (#2756 dispatch + BaseReporter reset) unblocked
TestBox-side bootstrap but the compat-matrix adobe2023 leg on
`c3e163213` still fails — only now the cfheader cascade traces to
`vendor/wheels/tests/runner.cfm:160`:

    cfheader(name="Access-Control-Allow-Origin", value="*");

By the time `testBox.run()` returns, Adobe CF (Undertow) has committed
the response — individual test specs writing output during the run
crossed the engine's buffer threshold and flushed mid-suite. The
runner's post-test `cfheader` / `cfcontent` calls then throw
`InvalidHeaderException: Failed to add HTML header`.

Two changes:

1. `$content()` in `vendor/wheels/Global.cfc` picks up the same
   `$responseCommitted()` short-circuit and try/catch shape as
   `$header()`, so callers in error paths or post-flush contexts get
   best-effort behaviour. Mirrors the existing `$header()` contract
   that landed in this PR.

2. The eight `cfheader` and four `cfcontent` sites in `runner.cfm`
   now route through `application.wo.$header()` / `$content()`. The
   runner already uses `application.wo` elsewhere (`$dbinfo`, etc.)
   so the wiring is consistent. The status-code header is what CI
   parsers key on; a committed response keeps whatever status the
   engine already wrote, and the JSON body still appends below.

This should be the third and final layer of the adobe2023/2025
unwind. The same defensive shape is now centralised on the two
framework helpers (`$header`, `$content`), so future tag wrappers
(`$location`, `$cache`, ...) can adopt it incrementally without
re-deriving the isCommitted probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(changelog): correct cfheader count from eight to six

Reviewer A flagged the same off-by-two count error in the commit body
of 024b08d under Correctness ("eight cfheader" — actual count is six)
but did not carry the check through to the CHANGELOG entry, which
contained the identical figure. Reviewer B caught the missed instance
on round-1 convergence.

Verified by grepping the runner.cfm diff: six `cfheader` sites
(statuscode=500, statuscode=417 ×2, statuscode=200 ×2, Allow-Origin)
and four `cfcontent` sites (application/json ×2, text/plain, text/xml).

Cosmetic only; the code is correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(model): env() defaults work on Adobe CF when caller omits fallback

`env(required string name, any default = "")` was throwing
`UndefinedElementException: Element DEFAULT is undefined in ARGUMENTS`
on Adobe CF 2023/2025 whenever called with a single positional arg
(`env("KEY")`) — the common case for "this env var must be set."

`default` is a CFML reserved word (switch/case/default), and Adobe
CF's argument-binding leaves the matching arguments-scope key
undefined rather than seeding it from the signature default. Lucee
and BoxLang seed it correctly, so the function appeared to work
everywhere except in the Adobe legs of the compat matrix — where
this single test (`envHelperSpec.cfc:28`) error escaped TestBox's
per-spec catch and propagated all the way up to `runner.cfm`,
poisoning the entire test run with an HTML error page.

Switch to defensive access — `StructKeyExists(arguments, "default")
? arguments.default : ""` — which behaves identically on engines
that seed the default (Lucee/BoxLang return `""` either way) and
fixes the Adobe path. Public `@default` API is unchanged; callers
that pass `default = "X"` keep working.

Surfaced after the three-layer dispatch/test-runner unmasking
(#2756) stopped onError-cascade `cfheader` failures from hiding the
real test exceptions. The compat matrix now actually runs Adobe CF
tests, exposing this one as the next blocker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(global): CHANGELOG entry + sync-guard comment for env() Adobe fix

Address Reviewer A nits on ac0fead:

1. Missing CHANGELOG `[Unreleased] > Fixed` entry for the env() Adobe
   CF reserved-word fix — adds the standard root-cause-chain prose
   matching the surrounding entries.

2. Latent risk in env(): the inline `""` fallback used on the Adobe
   CF path must stay in sync with the signature default. A future
   author changing one without the other would silently diverge
   between Adobe (defensive path) and Lucee/BoxLang (binder path).
   Adds a NOTE comment calling that out.

The misleading `model` scope on ac0fead is a past-commit comment
nit (env() lives in Global.cfc, not a model). CLAUDE.md notes scope
is optional and unrestricted, so it's documentation-only — not worth
amending and force-pushing for. Using `global` going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(global): rename env() default parameter to defaultValue

The earlier defensive-access fix (ac0fead) closed the
`UndefinedElementException` symptom on Adobe CF 2023/2025 when
callers passed no second arg, but it did not close the symmetric
bug: Adobe CF refuses to bind a parameter named `default` at all,
so even when a caller passes the second positional arg
(`env("KEY", "custom_default")`), `arguments.default` is undefined
and the function silently returns `""`. The current compat run
surfaced this as `envHelperSpec.cfc:33` reporting `Expected
[custom_default] but received []`.

Renaming the parameter to `defaultValue` is the only portable
shape — Adobe CF binds non-reserved names normally, and Lucee /
BoxLang bind any name including reserved words. The framework's
own specs use positional calls so they're unaffected.

Back-compat for the legacy named-arg form
`env(name = "X", default = "Y")` is preserved: named arguments
land in the arguments scope under their literal key regardless of
the declared parameter list, so checking `StructKeyExists(arguments,
"default")` first resolves legacy named-arg callers without
re-introducing the binding bug.

Docstring updated. CHANGELOG entry replaced (the previous
defensive-access description no longer matches the implementation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(global): cover env() back-compat named-arg path

Reviewer A nit on 176cf8b: the `StructKeyExists(arguments,
"default")` shim in `env()` is the load-bearing piece of the
back-compat story for the legacy `env(name = "X", default = "Y")`
named-arg form, but no spec exercised that shape.

Adds an `it` block asserting the legacy named-arg call still
resolves correctly. Regression catch — if the guard is ever
removed during a future refactor, or a future CFML engine
rejects `default` as a named-arg key at the call site, this
assertion fails before the named-arg form silently breaks
again.

The primary positional-arg path is already covered by the
adjacent `env("NONEXISTENT_KEY_12345", "custom_default")` spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(global): wrap headerSpec afterEach cleanup in best-effort try/catch

After the rest of this PR's fixes let Adobe CF 2023/2025 reach the
spec-execution stage, the compat-matrix adobe2023 leg surfaced one
more cascade — this time in `headerSpec.cfc`'s `afterEach`:

    cfheader(statuscode = 200);
    cfheader(name = "content-type", value = "text/html");

When an earlier spec in the same bundle writes test output that
crosses Adobe's buffer threshold, the response commits and the bare
`cfheader` calls in afterEach throw
`InvalidHeaderException: Failed to add HTML header`, which surfaces
as an opaque "Template" exception that takes down the whole bundle.

The bare-cfheader contract here is deliberate — using `g.$header()`
in the cleanup would let a regression in the unit-under-test mask
itself as a lifecycle error. Best path is keeping the bare calls
but wrapping each in its own `try/catch` so the cleanup is
best-effort (the committed-response case is expected on Adobe runs,
not a regression), while still exercising the bare-engine API.
Lucee 7 / BoxLang / Adobe 2018/2021 paths are unchanged — they
never reach the catch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(test): suppress inner-spec statuscode mutations from outer response

The previous test-runner layers stopped the cfheader cascade and let
TestBox actually run all 3697 specs on Adobe CF 2023/2025, returning
totalPass=3681 with 11 fails + 2 errors in the JSON. But the compat
matrix still failed with HTTP 404 because the OUTER response status
was being mutated by INNER test fixtures: specs calling
`processRequest()` hit Wheels routing that throws
`Wheels.RouteNotFound` and calls `$header(statusCode = 404)` via
`$throwErrorOrShow404Page`. That call goes through the framework's
`$header()` helper, which sees the response is uncommitted (it
typically isn't yet, mid-suite) and mutates the OUTER response
status to 404. By the time `runner.cfm` runs its own
`$header(statusCode = 200|417)` after `testBox.run()`, the response
has committed and the helper short-circuits — leaving the bleed-through
404 in place. The compat-matrix CI parser only accepts 200 or 417;
404 is treated as a runner crash and the JSON body is dropped.

Fix: introduce a `request.$wheelsTestSuppressStatusCode` flag that
runner.cfm opens before `testBox.run()` and closes after. While the
flag is active, `$header()` silently drops `statuscode` arguments and
falls through to header-only mutations (so name/value pairs like
Content-Type and Access-Control-Allow-Origin still land). Internal
spec fixtures' `statusCode = 404` calls now stay confined to the
controller-response struct that `processRequest()` returns to the
spec — exactly where they belong. After `testBox.run()` returns,
runner.cfm clears the flag so its own final `$header(statusCode = …)`
call lands normally.

The new flag is distinct from `request.$wheelsTestRun` (which keeps
its existing flash/cookie semantics) — different concerns, different
lifecycles, separate flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* revert(test): undo $wheelsTestSuppressStatusCode flag

The flag introduced in 66f23b2 was too aggressive — it suppressed
ALL inner statuscode mutations, including the legitimate ones that
specs depend on. `processRequest()` reads the controller-set status
code via `$statusCode()` (which queries the live servlet response),
so specs like:

    expect(g.processRequest(params={...}, returnAs="struct").status).toBe(403)

depend on the controller's `cfheader(statuscode=403)` ACTUALLY mutating
the response. Suppressing it broke 10 Lucee 7 + SQLite tests in the
LuCLI CI workflow (specs asserting custom status codes from
renderText/renderView and from controller methods that set 4xx).

Reverting both the flag declaration in runner.cfm and the suppression
branch in `$header()`. The Adobe CF outer-response-pollution issue
needs a different shape — save/restore in `processRequest()` (which
already has an attempted `$header(statusCode = 200)` reset at line
3493 that gets dropped when the response is committed) is the right
target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(test): pre-size response buffer + $content() void + $content() spec

Address Reviewer A round-6 CHANGES_REQUESTED on #2756.

1. **Pre-size response buffer in runner.cfm.** The reverted suppress-flag
   approach was wrong (it broke legitimate inner status-code reads), but
   the underlying problem is real: on Adobe CF 2023/2025 (Undertow), the
   default 8KB output buffer auto-flushes once any spec writes that
   crosses it — committing the response with whatever `statusCode = 404`
   an inner spec happened to set via `$throwErrorOrShow404Page`. Once
   committed, `runner.cfm`'s end-of-suite `$header(statusCode = 200|417)`
   is a no-op, and the compat-matrix CI parser sees HTTP 404 and drops
   the JSON body even though `totalPass = 3681`.

   Expanding the buffer to 16 MB before `testBox.run()` keeps the response
   uncommitted long enough for the final-status call to land. Wrapped in
   `try/catch` so engines that don't expose `setBufferSize` or reject the
   value fall through to the defensive `$header()` / `$content()` paths.

   This is a pragmatic stopgap for typical suites — a `processRequest()`-
   level save/restore is the durable shape and is tracked as a follow-up.
   The CHANGELOG entry no longer claims "third and final layer" now that
   the residual issue is named.

2. **`$content()` return type from `any` to `void`.** Matches `$header()`
   immediately below. The function has no return statement, so `any` was
   never accurate — and silently returned `null` to any accidental
   `result = $content()` callsite on every engine instead of producing a
   type error.

3. **Spec for `$content()` defensive path.** Parallels the `$header()`
   coverage in the same describe block. Exercises the plain-struct copy
   and the `$responseCommitted()` short-circuit on every engine in the
   compat matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(global): save/restore outer response status across processRequest

The buffer pre-sizing in f1f63b1 was a useful stopgap but doesn't
solve the underlying mechanism — Adobe CF 2023/2025 still ships HTTP
404 from the test runner whenever an in-suite `processRequest()`
spec exercises a Wheels.RouteNotFound path. The inner action calls
`$header(statusCode = 404)`, which lands on the shared servlet
response, and once the test runner's response commits the outer
`$header(statusCode = 200|417)` cannot override it.

This is the per-method save/restore the prior PR description named
as the durable fix. `processRequest()` now:

1. Captures `GetPageContext().getResponse().getStatus()` before the
   inner action runs (with a 200 fallback for engines or contexts
   where the probe fails).
2. Reads `$statusCode()` after the action returns — unchanged. This
   is what the spec assertion sees, so inner status mutations remain
   readable.
3. Restores the captured outer status via direct
   `getResponse().setStatus()` — best-effort. The servlet spec
   says `setStatus()` is a no-op on committed responses, but the bulk
   of in-suite `processRequest()` calls happen on uncommitted state
   thanks to the 16 MB buffer in `runner.cfm`, so the restore lands.
4. Falls through to the existing `$header(statusCode = 200)` /
   `$header(Content-Type)` reset which carries the legacy `cfheader`
   path for engines where the direct servlet call is unavailable.

The Lucee 7 LuCLI status-code specs (the ones the reverted
`66f23b2c` suppress-flag broke) still pass — the change only restores
the OUTER status; the spec's `expect(processRequest(...).status).toBe(403)`
reads the captured inner value, not the restored outer.

CHANGELOG entry rewritten to reflect the actual implementation
(direct `setStatus()` rather than the prior "tracked as follow-up"
phrasing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* revert(global): undo processRequest save/restore (caused new Adobe regression)

The save/restore in f958a33 was intended to prevent inner-spec
`statusCode = 404` from bleeding into the outer test-runner response.
The shape was correct in principle — capture outer status before the
inner action, restore via direct `setStatus()` afterwards — but it
exposed an Adobe CF / Undertow quirk that turns the fix into a net
regression:

- Undertow returns `0` as the response's initial status before any
  cfheader or setStatus call (vs Lucee/BoxLang's default 200).
- The save captures `0`. The restore calls `setStatus(0)`, leaving
  the response in an invalid state.
- Downstream, any controller calling `renderText(status = $statusCode())`
  picks up `0` as the default `status` argument. `$setRequestStatusCode`
  then calls `$returnStatusText(0)`, which throws
  `An invalid http response code 0 was passed in.` (rendering.cfc:785).

Compat-matrix results confirm the regression:
- f1f63b1 (without save/restore): 3682 pass, 11 fail, 2 error.
- f958a33 (with save/restore): 3681 pass, 10 fail, 4 error.

Net effect: one fewer fail but two more errors, and the new error
class is a framework-internal "invalid http response code 0" that
wasn't present before. The Adobe CF deep issues (status=0 default,
inner-spec bleed-through, response-commit timing under heavy test
output) need a more thorough redesign than a per-method save/restore
can deliver; tracking that as a separate PR.

Reverting both the capture and restore blocks. The existing
`$header(statusCode = 200)` / `$header(Content-Type)` reset path at
the bottom of `processRequest()` is restored as-is — it was already
the legacy best-effort reset, and it remains the right shape for
engines where it can write through. CHANGELOG entry updated to drop
the save/restore claim and explain the deferred-redesign reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: trim PR-narrative and historical context from inline comments

CLAUDE.md: "Don't reference the current task, fix, or callers — those
belong in the PR description and rot as the codebase evolves" and
"Never write multi-line comment blocks — one short line max."

Inline comments in $header(), $content(), $responseCommitted(),
env(), BaseReporter.resetHTMLResponse(), and runner.cfm's buffer
pre-size block all carried PR-description-shaped prose: PR numbers,
"this PR's other fixes," "since the matrix was added," step-by-step
narrative of the cascade. The why those callsites need defensive
shape (Adobe CF rejects cfheader/cfcontent on a committed response;
Adobe CF's argument binder skips `default` as a reserved word) is
invariant and worth keeping; the historical sequencing is not.

Trimmed to one-line WHY per defensive block and dropped the
PR-narrative paragraphs. Behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(test): trim multi-line back-compat comment in envHelperSpec

Reviewer B caught a multi-line block above the back-compat `it()`
that I missed in 97ccdf0. CLAUDE.md: "Never write multi-line
comment blocks — one short line max." Collapsed to a single
regression-guard line; the block's invariant-level WHY (env() was
renamed from `default` to `defaultValue` for Adobe CF) lives in
Global.cfc's `env()` comment, not duplicated here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: one-line WHY on the two empty catch blocks

Reviewer A: an empty `catch (any e) {}` on an unfamiliar Java interop
call looks like an oversight without a brief WHY. Both blocks
(runner.cfm's setBufferSize and BaseReporter's reset()) now carry a
one-line note naming the expected fall-through case — same shape, no
narrative prose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(view): $viteWalkImports passes parent struct so Adobe CF mutates the originals

Five viteSpec failures and one error on Adobe CF 2023/2025 trace to
the same Cross-Engine Invariant #6: Adobe CF copies arrays by value
out of struct literals. `$viteResolveAssets()` was building

    local.rv = {scripts: [], styles: [], preloads: []}

and then calling

    $viteWalkImports(preloads = local.rv.preloads, styles = local.rv.styles, ...)

On Adobe CF the `preloads` and `styles` arguments inside the walker
were independent copies, so every `ArrayAppend(arguments.preloads, ...)`
wrote to garbage that was discarded on return — leaving `local.rv`
empty. Lucee and BoxLang share the array references, so the walk
mutated the originals as intended, which is why the bug only showed
on Adobe.

Fix: pass the parent `rv` struct itself and mutate `arguments.rv.preloads`
/ `arguments.rv.styles`. Struct references are shared on every engine,
so the inner-struct arrays stay live across the recursion. This is the
pattern CLAUDE.md anti-pattern #6 calls out as the portable shape.

Function is internal — no external callers in the framework or specs
— so the signature change is safe. The viteSpec block tests
(`$viteResolveAssets > walks transitive imports...`, `dedupes diamond...`,
`terminates on cyclic...`, plus the four call-site specs for
`viteScriptTag`/`viteStyleTag`/`vitePreloadTag`) all assert the
walk's output shape, so they cover the regression directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(changelog): entry for vite walker Adobe CF array-by-value fix

Reviewer A nit on 96cf4b4 — every other substantive bug fix in this
PR has a CHANGELOG entry; the vite walker fix should too. Adds the
prose from the commit body under [Unreleased] > Fixed, matching the
surrounding entries' shape (root-cause-chain, fix description,
Cross-Engine Invariant reference, affected callers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(ai): document $responseCommitted() in cross-engine compatibility

Reviewer A nit on 4f0871d — the new `$responseCommitted()` helper
in `vendor/wheels/Global.cfc` is a public framework API but isn't
mentioned in the cross-engine doc that future tag-wrapper authors
will reach for. Adds a "cfheader / cfcontent on a Committed Response
(Adobe CF 2023/2025)" section right after the existing
`attributeCollection` discussion, documenting the probe-and-rethrow
pattern and pointing future helpers (`$location`, `$cache`,
`$htmlhead`, `$mail`, …) at `$responseCommitted()` as the canonical
shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: wrap $responseCommitted() docstring + add $content reset spec

Two non-blocking nits from Reviewer A round 12:

1. $responseCommitted()'s single-line docstring ran ~150 chars while
   the surrounding doc-blocks wrap at ~80 cols — split to three
   wrapped lines for consistency.
2. Added a spec covering `\$content(type=..., reset=true)`. `reset`
   is a boolean argument, and some engines are picky about boolean
   coercion through `attributeCollection`. The plain-struct copy
   logic is shared with `\$header()` (which has its own boolean-arg
   coverage via the `charset` combo), so this isn't fixing a known
   bug — it's an explicit regression guard for the second-most-common
   `cfcontent` call shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* docs(test): fix incorrect line-range citation in $content() spec comment

Reviewer A: the parenthetical "(Global.cfc:120-138)" pointed at
$content() itself rather than $header() (which lives at 140-165
now), so the citation referenced the wrong function. Collapsed the
six-line preamble to a single descriptive line; the cross-reference
was the only load-bearing detail and was wrong, so dropping it
removes the bug rather than just patching it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(view): $htmlhead defensive shape for committed-response (Adobe CF)

`cfhtmlhead` on Adobe CF 2023/2025 throws "Unable to add text to HTML
HEAD tag" once the servlet response has committed — same family as
the `cfheader` / `cfcontent` cases this PR's earlier layers fixed.

`$htmlhead()` now picks up the same `$responseCommitted()` probe-and-
rethrow shape: short-circuit if committed; on uncommitted call, wrap
`cfhtmlhead` in `try/catch` and rethrow only when the response is
still uncommitted (i.e. a genuine caller bug, not the race window).

This closes the three remaining viteSpec errors on Adobe CF:
- `viteScriptTag > emits stylesheet links for transitive chunk CSS`
- `viteScriptTag > emits modulepreload links for transitive chunks via $viteHtmlHead`
- `vitePreloadTag > emits via $viteHtmlHead and returns empty with default head=true`

All three call `$viteHtmlHead`, which captures into
`request.$viteHeadCapture` BEFORE delegating to `$htmlhead`. The test
assertion reads from the capture array, so the defensive no-op
preserves the test contract while preventing the underlying engine
error from escaping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… guard (#2759)

End-to-end smoke test of the apt + yum bucket-repo templates (with a
throwaway GPG key + Docker Ubuntu producer / Ubuntu+Fedora consumer
containers) against real v4.0.0 artifacts surfaced five bugs that would
block Phase 2 from working in production. Fixes them together because
they're all in service of issue #2605 standup.

1. release.yml: derive CHANNEL from version, not hardcoded `stable`.
   Every snapshot build was producing `wheels_<v>_amd64.deb` regardless
   of branch. The Phase 2 bleeding-edge receiver fetches
   `wheels-be_<v>_amd64.deb` from wheels-snapshots → 404 every time.
   Now: `contains(env.WHEELS_VERSION, '-snapshot')` selects
   `bleeding-edge` (which routes build-linux-packages.sh to
   nfpm-wheels-be.yaml).

2. release.yml: upload globs `wheels_*_amd64.deb` (underscore) didn't
   match `wheels-be_*_amd64.deb` (dash). Loosened to `wheels*_amd64.deb`
   + `wheels*.x86_64.rpm` so both channels' artifacts upload.

3. apt-repo/scripts/regenerate-apt-metadata.sh: first publish to a
   brand-new channel crashed — apt-ftparchive aborts on a missing
   pool scan path. Yum side already guarded; apt didn't. Now
   mkdir -p pool/<dist> at the top of the dist loop so empty-channel
   first-runs produce empty-but-signed metadata.

4. yum-repo/scripts/regenerate-yum-metadata.sh: nfpm produces unsigned
   RPMs but wheels.repo sets gpgcheck=1, so dnf rejects with
   "Package is not signed: GPG check FAILED". Now signs each .rpm
   via `rpm --addsign` before createrepo_c walks the dir. Also pins
   %__gpg to $(command -v gpg) — Ubuntu runners ship `gpg`, not the
   `gpg2` rpm's macro defaults to.

5. Both receiver workflows: `gpg --import-ownertrust` was being fed
   the 16-char key_id from `sec:` line field 5, but the format wants
   the 40-char fingerprint from `fpr:` line field 10. Emitted a
   "gpg: error in '[stdin]': invalid fingerprint" warning every run.
   Non-fatal but noisy; fixed by extracting the right field.

Plus: README + placeholder files updated to point at
`op://Wheels/wheels-linux-repo-signing/` (Wheels project vault on
my.1password.com) rather than `op://Infrastructure/` (PAI work tenant).

Verified end-to-end inside Docker against real v4.0.0 artifacts:
- Ubuntu 24.04 receiver produces apt + yum metadata, signs Release/
  InRelease/repomd.xml + each RPM
- Fresh Ubuntu 24.04 client: apt-get download wheels → success
  (MD5 matches Packages.gz)
- Fresh Fedora 40 client: dnf install --downloadonly wheels →
  "Complete!" with all signatures verifying

Once merged, the next develop snapshot will ship a properly named
wheels-be_<v>_amd64.deb / wheels-be-<v>.x86_64.rpm pair, and the
Phase 2 bucket repos (when created) will be able to consume them
without code changes. See #2605 for the operational standup checklist.

Signed-off-by: Peter Amiri <peter@alurium.com>
…#2758) (#2761)

* fix(release): emit .zip.{sha512,md5} sidecars + add Scoop install gotchas to docs

Three converging fixes that together close out the user-visible install
breakage reported in #2758 (and by two additional users on the scoop-wheels
side: zainforbjs#2 and a private report from Michael at PAI).

### 1. release: emit `*.zip.sha512` / `*.zip.md5` instead of `*.sha512` / `*.md5`

The scoop-wheels bucket's `autoupdate` config substitutes `$url.sha512` to
locate each artifact's checksum sidecar — i.e. for
`wheels-core-X.X.X.zip` it looks for `wheels-core-X.X.X.zip.sha512`. The
build scripts were emitting `wheels-core-X.X.X.sha512` (no `.zip.`
segment), so even if Excavator (or any other autoupdate path) ran post-GA
it would 404 on every non-module artifact. wheels-module already used the
correct `.zip.sha512` shape; the four other build scripts didn't.

Touched:
- `tools/build/scripts/build-{base,cli,core,starterApp}.sh`: emit
  `<artifact>.zip.{md5,sha512}` (was `<artifact>.{md5,sha512}`).
- `tools/build/scripts/README.md`: documents the new filename shape.
- `.github/workflows/release.yml` + `release-candidate.yml`: update the
  `actions/upload-artifact` glob patterns to match — 40 line edits across
  the two files. No consumers grep for the old shape (verified across
  homebrew-wheels, scoop-wheels, .deb/.rpm builders, framework code).
  snapshot.yml inherits via `uses: ./.github/workflows/release.yml`.

### 2. docs: add missing `scoop bucket add java` step

`scoop install wheels` declares `java/openjdk21` as a dependency but Scoop
does not auto-add the `java` bucket on the user's behalf — both real-user
reports (Michael, zainforbjs) hit the same "Couldn't find manifest for
'openjdk21' from 'java' bucket" warning before being able to proceed. The
docs claimed it was automatic. Updated:
- `start-here/installing.mdx` Windows tab (both v4-0-0 and v4-0-1-snapshot)
- `command-line-tools/installation.mdx` (both v4-0-0 and v4-0-1-snapshot)

### 3. docs: document `lucli modules install --url=` for standalone-LuCLI users

New "Advanced: existing LuCLI install" section under "Manual JAR install"
in `command-line-tools/installation.mdx` (both versions). Covers stable
and bleeding-edge URLs, and an Aside enumerates what the user gives up vs.
the Scoop/Homebrew/.deb/.rpm paths: binary name, LUCLI_HOME isolation,
JDK resolution, SQLite shim, channel awareness, upgrade story. Triggered
by Jay Dee's report (#2758) of trying `lucli modules install wheels@be`
based on inferred channel naming — the `@be` tag doesn't exist in LuCLI's
registry, and the bare `wheels` entry points at a stale `wheels-cli-lucli`
mirror that hasn't been synced from develop since 2026-04-04.

### Not in this PR (deliberate scope split)

- Real sha512 hashes for v4.0.0 in `scoop-wheels/bucket/wheels.json` — that
  lives in a different repo. Already up as wheels-dev/scoop-wheels#3 and
  unblocks the immediate install failure.
- Deciding whether to register scoop-wheels with Excavator or self-host a
  cron-driven autoupdate workflow — needs a design call, follow-up after
  the immediate-fix PRs land.
- Updating LuCLI's bundled registry (`local.json:23`) to point at
  `wheels-dev/wheels` directly instead of the stale `wheels-cli-lucli`
  mirror — needs Mark's input on the LuCLI side.

Refs #2758
Refs wheels-dev/scoop-wheels#2

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: address Reviewer A/B consensus findings (round 1)

- README at tools/build/scripts/: add build-starterApp.sh to the
  scripts list and add wheels-starter-app artifact entries to the
  output-structure diagram (header text bumped from "three" to "four"
  Wheels variants).
- installation.mdx (v4-0-0 + v4-0-1-snapshot): replace the fragile
  curl | sed pipeline in the new "Advanced: existing LuCLI install"
  section with jq, and add an empty-tag guard so an API failure or
  rate-limit miss fails fast with a useful message instead of
  silently 404-ing on a malformed URL.
- CHANGELOG.md: add ### Fixed entries under [Unreleased] for the
  .zip.{sha512,md5} sidecar rename and the Windows scoop bucket add
  java prerequisite.
- PR description: add a manual smoke-test checkbox covering the
  bleeding-edge lucli modules install --url= snippet.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* ci: dispatch scoop-wheels, retire chocolatey

The downstream package-manager dispatch step has fired wheels-released
events to wheels-dev/homebrew-wheels and wheels-dev/chocolatey-wheels.
Swap chocolatey out for scoop-wheels — the v1.x chocolatey-wheels
package is CommandBox-based and can't drive v4, so the Windows install
path moved permanently to Scoop in 4.0. scoop-wheels#4 adds the
autoupdate workflow on the consumer side that listens for this dispatch
event.

Touches the dispatch loop, the secret docstring, the in-step warning
message, and the three sibling comments (apt/yum dispatch + bump-develop)
that referenced the homebrew/chocolatey step as their channel-derivation
template.

No functional change for stable v4.0.0 (it already shipped). Takes
effect on the next snapshot or 4.0.1 release.

Refs #2758
Refs wheels-dev/scoop-wheels#4

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: address Reviewer A/B consensus findings (round 2)

- tools/build/scripts/README.md:36 — "three variants" to "four
  variants" in the Convenience Scripts section (line 3 was fixed in
  round 1; line 36 was missed).
- web/sites/guides/src/content/docs/v4-0-0/command-line-tools/installation.mdx
  (lines 76-77, 84-85) and the matching v4-0-1-snapshot mirror —
  replace the `sed -nE` tag-resolver in the bleeding-edge deb/rpm
  snippets with `jq -r '.[0].tag_name | sub("^v"; "")'` plus the
  same empty-tag guard added to the LuCLI section in round 1.

Both items map to Reviewer B's converged-changes verdict on SHA
a81345a; Reviewer A's review of
the round-1 commit (`a81345ae`) and the round-2 commit (`5144fdbb`)
flagged the same carry-overs.

Refs #2758

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs: address Reviewer A/B consensus findings (round 3)

- Replace remaining `curl | sed -nE` pipelines in the stable Linux
  `.deb`/`.rpm` install snippets (lines 60 and 67 in both
  `v4-0-0/command-line-tools/installation.mdx` and
  `v4-0-1-snapshot/command-line-tools/installation.mdx`) with the
  same `jq -r '.tag_name | sub("^v"; "")'` pattern plus empty-tag
  guard that round 1 applied to the LuCLI section and round 2
  applied to the bleeding-edge `.deb`/`.rpm` snippets. The two
  parallel snippet pairs in each file (stable + bleeding-edge) now
  use the same resolver and error-handling path.

Reviewer B's round-1 converged-changes on SHA `5144fdbb9` also
listed amending the type of commit `5144fdbb9` from `release:` to
`ci:` to satisfy commitlint. That requires rewriting published git
history, which is outside the bot's allowed git operations
(read-only `git` per `_shared-rails.md`). It is left for a human to
amend, squash-merge with a conforming subject, or override the
DCO/commitlint check at merge time.

Refs PR #2761.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
#2762)

scoop-wheels#4 inlined OpenJDK 21 in the Scoop manifests, so
`scoop install wheels` and `scoop install wheels-be` now work from a
fresh shell with no prerequisite buckets. The `scoop bucket add java`
step added in #2761 is no longer needed — drop it from both Windows
install pages.

Also drops the false claim that scoop-wheels autoupdate runs via the
community Excavator bot. The bucket now uses its own
`.github/workflows/autoupdate.yml` that listens for `repository_dispatch`
fired by this repo's `release.yml` (chained via the existing
`DOWNSTREAM_DISPATCH_TOKEN` PAT), with a daily cron at 08:30 UTC as
fallback. End-to-end latency ~5-7 min from upstream tag to manifest.

Updates installing.mdx + command-line-tools/installation.mdx in both
v4-0-0 and v4-0-1-snapshot doc trees.

Signed-off-by: Peter Amiri <peter@alurium.com>
Lucee's CFML parser interpreted `#2345)` in the packages-command help text
as the start of an expression interpolation and aborted compilation of
the whole Module.cfc with `Invalid Syntax Closing [#] not found at
[2165:82]`. This crashed Phase 3 of tools/ci/smoke-test-module.sh
("Scaffold a scratch app") because `wheels new` triggers eager Module.cfc
compilation as soon as LuCLI loads the wheels module — see the failing
snapshot run 26050790202 / job 76587148389.

The repo's CLAUDE.md anti-pattern #14 covers this exactly: literal `#`
in a string literal MUST be escaped as `##` (the form Lucee 5/6/7,
Adobe CF 2018–2025, and BoxLang all agree on). Comments are fine —
which is why the 15+ other `#NNNN` issue refs in Module.cfc compile
without trouble; they all live in `//` or `/* */` blocks.

Why this slipped past PR CI: smoke-test-distribution is only wired
into release.yml's job graph, which runs on push-to-main or via
workflow_call from snapshot.yml. PR CI exercises the framework test
suite but not the built-and-installed distribution. So parse errors
in cli/lucli/Module.cfc surface only after merge to develop, when the
next snapshot build kicks off.

Signed-off-by: Peter Amiri <peter@alurium.com>
* docs: drop `scoop bucket add java` step, update autoupdate description

scoop-wheels#4 inlined OpenJDK 21 in the Scoop manifests, so
`scoop install wheels` and `scoop install wheels-be` now work from a
fresh shell with no prerequisite buckets. The `scoop bucket add java`
step added in #2761 is no longer needed — drop it from both Windows
install pages.

Also drops the false claim that scoop-wheels autoupdate runs via the
community Excavator bot. The bucket now uses its own
`.github/workflows/autoupdate.yml` that listens for `repository_dispatch`
fired by this repo's `release.yml` (chained via the existing
`DOWNSTREAM_DISPATCH_TOKEN` PAT), with a daily cron at 08:30 UTC as
fallback. End-to-end latency ~5-7 min from upstream tag to manifest.

Updates installing.mdx + command-line-tools/installation.mdx in both
v4-0-0 and v4-0-1-snapshot doc trees.

Signed-off-by: Peter Amiri <peter@alurium.com>

* docs: add `scoop install git` prerequisite before bucket add

A real-user run from a fresh Windows shell on 2026-05-18 surfaced a
missing prerequisite: Scoop's installer doesn't ship git, but
`scoop bucket add` uses `git clone` under the hood, so adding any
third-party bucket fails with:

    ERROR Git is required for buckets. Run 'scoop install git' and try again.

`scoop install git` itself works from a totally fresh shell because the
default `main` bucket — where git lives — is bundled with the Scoop
installer rather than fetched at runtime. So the corrected sequence is:

    irm get.scoop.sh | iex
    scoop install git
    scoop bucket add wheels https://github.com/wheels-dev/scoop-wheels
    scoop install wheels

Adds the `scoop install git` step (with a brief why-it's-needed note)
to both Windows install pages in both v4-0-0 and v4-0-1-snapshot doc
trees. The detailed `installation.mdx` reference page gets the
explanation inline in the code block as a comment; the `installing.mdx`
quick-start gets a numbered step with prose below.

Bumps step numbering in the start-here `installing.mdx` Steps blocks
(3 → 4 for the verify step).

Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
)

Real-user Scoop install on 2026-05-18 surfaced: `wheels --version` from
a fresh `scoop install wheels` (stable) reports `4.0.0-SNAPSHOT+1442` —
a snapshot tag, on a stable channel install of GA v4.0.0.

Root cause
----------

`cli/lucli/.module-version` was tracked in git with a hardcoded snapshot
string. The release-pipeline `@build.version@` substitution at
release.yml:270 targets `*.json`, `*.md`, `*.cfm`, and `*.cfc` — but
not `.module-version` (no extension). So every release artifact (stable
GA, RC, every snapshot) shipped the same stale source-tracked value
regardless of what was actually being released.

This file is the source of truth for two runtime behaviors in the
brew/scoop/.deb/.rpm wrappers:

1. `wheels --version` reads it and prints the content verbatim.
2. The first-run sync compares staged-module-version against
   installed-module-version to decide whether to re-sync. When every
   artifact has the same stale string, the comparison always sees "no
   change" and silently skips the sync — which means a user running
   `scoop uninstall wheels; scoop install wheels-be` would keep the
   OLD stable module on disk under ~/.wheels/modules/wheels/. Channel
   switching was broken in subtle ways.

CFC code at PackagesMainCli.cfc:340 already documents that
.module-version "isn't written" in dev checkouts, so the architecture
already expected this to be a build-time artifact, not source-tracked.

Fix
---

- `git rm cli/lucli/.module-version` — drop the stale source file.
- `.gitignore` entry so it can't be re-introduced accidentally.
- `release.yml`: write `cli/lucli/.module-version` from `MODULE_VERSION`
  right after the `@build.version@` substitution, before tar/zip pack.

snapshot.yml uses release.yml as a reusable workflow, so it inherits.
release-candidate.yml doesn't build a wheels-module artifact at all
(RCs aren't published to Scoop), so it doesn't need touching.

Effect
------

- `wheels --version` on next snapshot/release: reports the actual
  release tag, not a stale snapshot number.
- Channel switching detects the version change correctly and re-syncs.
- Dev checkouts (without `.module-version`) fall through to the Tier 2
  BuildInfo.cfc lookup at PackagesMainCli.cfc:341, unchanged.

No effect on already-shipped 4.0.0 GA install — users with the bad
version baked in will see the correct value on their next
`scoop update wheels` once 4.0.1 ships through the autoupdate chain.

Refs #2758 follow-up

Signed-off-by: Peter Amiri <peter@alurium.com>
… bat-jar pre-parse (#2766)

* fix(cli): scoop wheels.cmd invokes java.exe directly to dodge cmd.exe bat-jar pre-parse

The Scoop-installed wheels.cmd wrapper failed on every invocation on at
least one Windows 11 build (10.0.26200.8457). cmd.exe pre-parses the
entire body of a `call <bat>` target looking for labels and control
flow before running it; the lucli-<ver>.bat artifact is a bat-jar
concatenation (bat preamble + :JAR_BOUNDARY + raw JAR ZIP bytes, ~915
KB), and the pre-parser trips on bytes inside the ZIP tail and aborts
with `The filename, directory name, or volume label syntax is
incorrect.` before lucli ever executes.

Dispatch lucli via `"%JAVA_HOME%\bin\java.exe" -client -jar "%~dp0lucli-<ver>.bat" %*`
instead. java reads the JAR via stream and skips the bat preamble in
front of the ZIP central directory, sidestepping cmd.exe's parser
entirely. The same fix applies to the `:deploy_dispatch` branch added
by PR #2691, which used the same `call` pattern with rewritten args.

Add a JAVA_HOME resolver near the top of the dispatch path that
locates the openjdk21 dependency declared via `depends: java/openjdk21`
-- preferring `%SCOOP%\apps\openjdk21\current` (the canonical Scoop
layout) and falling back to the sibling-app form
`%~dp0..\..\openjdk21\current`. Fail fast with a `scoop install
java/openjdk21` hint when neither is found.

Also restores the :deploy_rewrite block to build-manifests.py so the
Python source-of-truth matches the published JSON (the block was added
direct-to-JSON in #2691 and never made it back to the generator);
without this, a future `python3 build-manifests.py` would wipe both
the deploy rewrite AND this fix.

Closes #2765.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

* docs(web/guides): add scoop bucket add java step; fix JAVA_HOME prose for Windows install

The Scoop packages declare `depends: java/openjdk21` — Scoop's `depends:`
does not auto-add the dependency bucket, so the `java` bucket must be added
explicitly before `scoop install wheels`. The `wheels.cmd` wrapper now
resolves `JAVA_HOME` from that dependency automatically (#2765/#2766).

Corrects the "no separate java bucket, no JAVA_HOME setup" claim that was
wrong since the openjdk21 dependency was introduced.

Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

---------

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…ive (#2767)

The Scoop draft generator at tools/distribution-drafts/scoop/ produced
two .json manifests intended to be copied into wheels-dev/scoop-wheels
at bucket setup time. After the inline-JDK rework in scoop-wheels@3f22250
(May 18), the bucket grew its own self-hosted autoupdate workflow and
diverged from this draft on every meaningful axis:

- inline JDK as 5th URL vs. external `depends: java/openjdk21`
- real auto-bumped hashes vs. placeholder zeros
- self-hosted autoupdate workflow vs. Scoop built-in `autoupdate` block
- JAVA_HOME fallback + direct `java -jar` invocation in wrapper
  (scoop-wheels#6) vs. `call lucli.bat` here

The parent README originally framed this directory as a "useful diff
signal during release reviews" -- in practice nobody acted on the
divergence between May 18 and today (two snapshot releases later),
and the stale drafts only added a misleading second source-of-truth.
wheels#2765 surfaced the gap when a user hit a wrapper bug that had
already been quietly fixed-then-undone in the divergence.

Delete the scoop subdirectory and trim the two scoop rows + the
keep-or-remove paragraph from the parent README. Bucket is now the
unambiguous home; structural changes happen there directly.

No CI consumed these files -- release.yml only references
linux-packages/apt-repo/yum-repo from this tree. scoop-wheels#6 (the
wrapper fix) and scoop-wheels#7 (tracking) remain valid in the bucket
repo.

Closes wheels#2765

Signed-off-by: Peter Amiri <peter@alurium.com>
Promote `## [Unreleased]` to `# [4.0.1] => 2026-05-20` so `release.yml`'s
release-checklist step (which refuses to publish on `=> TBD`) and its
`awk '/^# \[VERSION\]/,/^---$/'` release-notes extraction both pick up
the section.

Change the section terminator from `----` (4 dashes) to `---` (3 dashes)
— mirrors the same fix shipped in #2606 for the 4.0.0 cut. The awk range
in `release.yml` matches only `^---$`, so a 4-dash terminator would let
the extraction bleed into 4.0.0's notes.

`wheels.json` is already at `4.0.1` (auto-bumped by
`bump-develop-version.yml` after the 4.0.0 GA), so no version bump is
needed here.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merging develop into main for the 4.0.1 patch cut. The v4.0.0 cut's
tree-alignment left main's tree byte-identical to the v4.0.0 tag, so
this merge is a clean forward-merge of develop's 103+1 new commits.
Using `--no-ff -X theirs` as a safety net mirroring the v4.0.0 pattern
(#2607); no actual conflicts expected.

Pre-release archive branches preserve both prior tips:
  - archive/develop-pre-v4.0.1 → 63777a0
  - archive/main-pre-v4.0.1    → 91e7398

Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri
bpamiri merged commit 44e9249 into main May 20, 2026
2 checks passed
@bpamiri
bpamiri deleted the release/v4.0.1-to-main branch May 20, 2026 15:46
bpamiri added a commit that referenced this pull request May 27, 2026
)

The 4.0.2 release push (#2819) failed before any step ran: the pre-hooks of
Wandalen/wretry.action@v3 -- the lone Node 20 action in the job, wrapping
softprops/action-gh-release -- threw "Argument list too long" spawning the
node20 binary. Root cause is GitHub's forced Node 20 -> 24 runner migration
(forced default 2026-06-02), not workflow config: run #2769 (4.0.1) passed
7 days earlier with the identical setup, and the develop snapshot path (same
action) succeeded 16 min before on a pre-migration runner.

- Set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at workflow level so all JS
  actions (including wretry@v3) run on Node 24, bypassing the broken node20
  launch path. This is GitHub's documented remedy for the transition.
- Add a workflow_dispatch trigger so a stuck/failed release can be re-fired
  from the Actions tab without a fresh push. Channel selection keys on
  GITHUB_REF: dispatch against main -> stable, develop -> snapshot.

Merging to main re-fires release.yml and completes the v4.0.2 release; prior
failed attempts created no tag/release, so there is no double-publish risk.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant