Repository navigation
Conversation
…cope
scope() declared no callback argument, so the documented `.scope(path="/x", callback=function(map){...})` form (and namespace()/package()/controller(), which forward to scope()) silently dropped it: the callback never ran (its routes 404'd) and nothing closed the scope, so every route declared after inherited the scope's path prefix and middleware.
scope() now consumes callback the same way group() does — runs callback(this) then auto-end()s — using the cross-engine-proven IsCustomFunction guard. Adds MapperRobustnessSpec coverage for the scope/namespace/package callback forms plus the no-swallow and no-middleware-leak guarantees for routes declared after the block.
Refs #3072
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Three focused updates: - Anti-pattern #3: note that scope()/namespace()/package()/controller() also accept callback= and auto-close the scope (not just resources()) - Middleware Quick Reference: use callback form for route-scoped .scope() - Route Model Binding: use callback form for the .scope(binding=true) example Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Wheels Bot — Docs updatedAdded a doc commit to this PR:
The five user-guide MDX pages (rate-limiting, cors, multi-tenancy, route-model-binding, routing) already use the correct |
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR makes scope() consume a callback= argument the same way group() already does (run callback(this), then auto-end()), so the documented .scope(path="/api", callback=...) form — and namespace()/package()/controller(), which forward to scope() — stop silently dropping the callback and leaking the scope's path/middleware onto every later route. The implementation is correct, mirrors proven prior art in the same file, and ships failing-first specs that assert the exact failure modes from #3072. Verdict: comment — no blocking findings, two minor nits below.
Correctness
Verified clean — no findings. Evidence checked:
- Consume-before-prepend ordering is right. The callback is lifted into
localand deleted fromargumentsatvendor/wheels/mapper/scoping.cfc:37-40, beforeArrayPrepend(variables.scopeStack, arguments)at line 103, so the callback can no longer pollute the scope stack. Invocation +end()happen after the prepend (lines 109-112), so nested routes correctly see the new scope. - Cross-engine guard is proven prior art, not new risk. The
StructKeyExists(...) && IsCustomFunction(...)shape is identical togroup()atscoping.cfc:207andresources()atvendor/wheels/mapper/resources.cfc:44, both of which already pass the full engine matrix — including no-callback call sites (vendor/wheels/tests/specs/mapperModernSpec.cfc:94calls.group(path="admin")with no callback). The BoxLang null-argument branch atscoping.cfc:94-99is untouched and unaffected, sincecallbackis removed fromargumentsbefore that copy runs. - Wrapper forwarding works without declaring the param.
namespace()/package()/controller()forward viaargumentCollection = arguments(scoping.cfc:134/149/163), so an extracallback=named argument reachesscope()'s declaredany callbackon every engine; the specs confirm this fornamespace()andpackage(). - Behavior-change blast radius is nil. Anyone who previously wrote
.scope(callback=fn).end()had completely broken routing (the callback's routes 404'd per #3072), so no working code depends on the old open-scope behavior; a now-redundant trailing.end()fails loudly withWheels.InvalidRoute("Unbalancedend()call",vendor/wheels/mapper/mapping.cfc:30-36) rather than silently misrouting.
One non-blocking observation: a non-function callback value (e.g. a string) is consumed and silently ignored, leaving the scope open. That exactly matches group()'s existing semantics (scoping.cfc:207), so it's consistent — but if you ever harden this, a dev-mode throw would fit the repo's recent "fail loudly" direction (cf. the end() underflow guard). Not requested here.
Tests
- Coverage is good: callback-runs + path-prefix, auto-close (no route swallowing), middleware no-leak,
namespace()package+path prefixing, andpackage()package-without-URL-prefix are all asserted invendor/wheels/tests/specs/mapper/MapperRobustnessSpec.cfc:183-281, written failing-first per the PR body. The spec extendswheels.WheelsTest(BDD, not RocketUnit) and reuses the file's existingbeforeAll/afterAllroute-snapshot hygiene (lines 12-24). - Minor nit:
controller()also gains callback support via the same forwarding path (scoping.cfc:163) and is named in the new CLAUDE.md line, but has no spec. It's deprecated per its docblock, so this is optional — a one-it()addition would close the gap if you want the documented surface fully pinned.
Docs
- Changelog fragment
changelog.d/3072-scope-namespace-callback.fixed.mdis present, correctly named (<slug>.fixed.md), and is a complete bullet line — no directCHANGELOG.mdedit. Good. - Minor nit: the PR body's Feature Completeness Checklist still says CLAUDE.md — left for
bot-update-docs.ymlfollow-up, but the second commit (5e2302f9,docs: update scope()/namespace() callback= examples in CLAUDE.md (#3072)) updates CLAUDE.md in this PR. The CLAUDE.md edits themselves are accurate against the implementation (the auto-close note and both example rewrites matchscoping.cfc); just tick that checklist row so the body reflects the actual diff. The guides MDX call sites remain correctly deferred.
Commits
Both commits conform to commitlint.config.js: valid types (fix, docs), headers ≤ 100 chars, not ALL-CAPS, and bodies explain the why (silent drop → 404 + scope leak). Both carry Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> matching the commit author. The PR title is itself a valid conventional-commit header, which is what the squash-merge gate lints.
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Re-review at head 5e2302f9 (the prior wheels-bot review was marked against 19acb5c3). This PR makes scope() consume callback= the way group() already does — run callback(this), then auto-end() — so the documented .scope(path="/api", callback=...) form, and the namespace()/package()/controller() wrappers that forward to scope(), stop silently dropping the callback and leaking the scope's path/middleware onto every later route. The implementation is correct, reuses cross-engine-proven prior art in the same file, ships failing-first specs, and the in-PR CLAUDE.md doc updates are accurate against the code. Verdict: comment — no blocking findings; the same two minor nits from the previous round remain open.
Correctness
Verified clean — no findings. Evidence checked at this head:
- Consume-before-prepend ordering.
callbackis lifted intolocaland deleted fromargumentsatvendor/wheels/mapper/scoping.cfc:37-40, beforeArrayPrepend(variables.scopeStack, arguments)at line 103, so it never pollutes the scope stack. Invocation +end()(lines 109-112) happen after the prepend, so nested routes see the new scope. - Wrapper forwarding.
namespace()/package()/controller()forward viaargumentCollection = arguments(scoping.cfc:134/149/163), socallback=reachesscope()'s declaredany callbackparam; the new specs confirm this fornamespace()andpackage(). - No working code regresses. Anyone who previously wrote
.scope(callback=fn)had broken routing (the callback never ran — that is the bug per #3072), so nothing depends on the old open-scope behavior. A now-redundant trailing.end()fails loudly via the underflow guard ("Unbalancedend()call",vendor/wheels/mapper/mapping.cfc:33) rather than silently misrouting. - Error path: a callback that throws leaves the scope open — identical to
group()'s existing semantics (scoping.cfc:207-210), and routes-config exceptions abort app start anyway. Consistent, non-blocking.
Cross-engine
Verified clean. The one new shape I probed: on BoxLang, declared-but-unpassed params can sit in arguments (that's why the copy loop at scoping.cfc:94-99 checks isNull(arguments[local.k])), so I checked whether line 37's StructKeyExists(arguments, "callback") could fire on every no-callback scope() call there. Refuted by prior art: group() uses the identical StructKeyExists + IsCustomFunction guard (scoping.cfc:207), as does resources() (vendor/wheels/mapper/resources.cfc:44), and mapperModernSpec.cfc:94 exercises .group(path="admin") with no callback on the full engine matrix today. The guard demonstrably does not fire for unpassed callbacks on any engine, so the new local.callback = arguments.callback assignment is unreachable in that case. The local.callback(this) invocation is a scope-qualified call (not the Adobe-crashing obj["key"]() bracket form), and the inline-closure-as-named-arg shape in the specs is a method call, not a constructor — both already proven by the group()/api()/version() specs in the same file.
Tests
- Coverage is good: callback-runs + path prefix, auto-close (no route swallowing), middleware no-leak,
namespace()package+path prefixing, andpackage()package-without-URL-prefix, atvendor/wheels/tests/specs/mapper/MapperRobustnessSpec.cfc:183-281. BDD spec extendingwheels.WheelsTest, reusing the file's existing route-snapshot hygiene (beforeAll/afterAll, lines 12-24). PR body documents the failing-first run. - Minor nit (carried over):
controller()also gains callback support via the same forwarding path (scoping.cfc:163) and is named in the new CLAUDE.md line, but has no spec. It's deprecated per its docblock (scoping.cfc:153), so optional — oneit()would pin the documented surface fully.
Docs
- Changelog fragment
changelog.d/3072-scope-namespace-callback.fixed.mdis present, correctly named, a complete bullet line, no directCHANGELOG.mdedit. Good. - The CLAUDE.md edits (anti-pattern #3 note; route-model-binding and middleware examples switched to the callback form with the now-redundant
.end()removed) are accurate against the implementation. - Minor nit (carried over): the PR body's Feature Completeness Checklist still says CLAUDE.md — left for
bot-update-docs.ymlfollow-up, but commit5e2302f9updates CLAUDE.md in this PR. Please tick that row so the body reflects the actual diff. The guides MDX call sites remain correctly deferred.
Commits
Both commits (19acb5c3 fix(router), 5e2302f9 docs) conform to commitlint.config.js: valid types, headers ≤ 100 chars, not ALL-CAPS, and both carry Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> matching the commit author email.
…sides now that scope() honors it #3092 and #3095 landed caution asides on develop (routing.mdx, cors.mdx, rate-limiting.mdx) stating scope()/namespace() silently ignore callback=, tracked in #3072. This branch fixes exactly that, so merge develop in and rewrite the three statements: document the now-supported callback= form (auto-closing the scope), keep the unclosed-scope warning for the explicit .end() form, and note that releases <= 4.0.3 still ignore callback=. Adds a compile-tested namespace(callback=) example to routing.mdx, matching the already-correct callback examples in route-model-binding.mdx and multi-tenancy.mdx. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Re-review at head 44b20820 (prior wheels-bot reviews covered 19acb5c3 and 5e2302f9, both comment-state). The delta since the last reviewed head is a merge of origin/develop plus one docs commit that flips the three "callback= silently ignored" guide asides (routing.mdx, cors.mdx, rate-limiting.mdx) to document the now-supported callback= form. The framework change itself (vendor/wheels/mapper/scoping.cfc) is byte-identical to what the previous two reviews verified — the merge did not touch it — and the new guide text is accurate against the implementation and the release history. Verdict: comment — no blocking findings; three minor nits below (one new, two carried over).
Correctness
Verified clean at this head — no findings.
- The merge introduced no code drift.
git show 44b20820:vendor/wheels/mapper/scoping.cfcshows the consume-before-prepend block (lines 37–40), theArrayPrepend(line 103), and theIsCustomFunction+callback(this)+end()block (lines 105–112) exactly as reviewed at19acb5c3/5e2302f9. The new specs inMapperRobustnessSpec.cfc:183-281are likewise unchanged. - New guide claims are accurate. "runs the callback and closes the scope automatically when it returns" matches
scoping.cfc:109-111(local.callback(this); end();). The "(4.0.3 and earlier)" qualifier is correct: v4.0.3 is the latest release (cut 2026-06-10, pergh release list) and predates this fix, so every release ≤ 4.0.3 has the silently-ignored behavior — and the phrasing stays correct regardless of which version the fix ships in. - The flipped asides keep the true part. All three files retain the unclosed-scope warning for the explicit
.end()form (e.g.routing.mdx:122), which is still real — only the "callback is silently ignored" claim was removed, version-scoped to pre-fix releases. Agit grepsweep at this head finds no remaining stale "scope() does not take a callback" claims in the guides. - Coverage of issue #3072's five guide call sites is now complete in-PR. Three are edited here (rate-limiting.mdx:208, cors.mdx:117, routing.mdx "Namespaced sections"); the other two (multi-tenancy.mdx:271, route-model-binding.mdx:118) already teach the callback form and become correct as written by the framework fix itself — no edit needed, and neither carries a stale warning.
Cross-engine
Clean. The delta is docs-only. The one compiled artifact is the new {test:compile} snippet in routing.mdx:109-119: an inline closure passed as a method named argument (.namespace(name="admin", callback=function(map){...})), which is not the Adobe-crashing constructor-named-arg shape (Cross-Engine Invariant 5) and is the exact shape already shipped in multi-tenancy.mdx:271 and exercised by the new specs. {test:compile} is the established fence convention in this file (7 pre-existing uses).
Tests
- No new tests needed for the docs delta; the framework specs from the earlier commits stand (
MapperRobustnessSpec.cfc:183-281, failing-first per the PR body, plus the green "Bot PR TDD Gate" check at this head). - Minor nit (carried over from both prior reviews):
controller()also gains callback support via theargumentCollection = argumentsforwarding atscoping.cfc:163and is named in the CLAUDE.md and routing.mdx text, but has no spec. It's deprecated per its docblock, so optional — oneit()would pin the documented surface fully.
Docs
- Changelog fragment
changelog.d/3072-scope-namespace-callback.fixed.mdpresent and correctly formed; no directCHANGELOG.mdedit. CLAUDE.md and the three guide edits are accurate against the implementation. - Minor nit (carried over, now broader): the PR body is stale against the diff. The Feature Completeness Checklist still marks Framework Docs and CLAUDE.md as "left for
bot-update-docs.ymlfollow-up", and the Related Issue section says "the five guide MDX call sites the issue lists … are handled separately" — but commit5e2302f9updates CLAUDE.md and commit44b20820updates three of the five guides, with the remaining two correct as written post-fix. Please tick those checklist rows and refresh the Related Issue paragraph; with the docs now fully covered in-PR,Refs #3072could arguably becomeCloses #3072(author's call — the issue also tracks theextends="app.controllers.Controller"side note).
Commits
- The PR title (
fix(router): scope()/namespace() honor callback= and auto-close the scope, 75 chars) is a valid conventional-commit header, and that is what gates:.github/workflows/pr.yml:26lints only$PR_TITLE, the "Validate Commit Messages" check is green at this head, and the repo squash-merges so the title becomes the landing subject. - Minor nit (new, non-blocking): the new commit's header
docs(web/guides): flip the three #3072 'callback= silently ignored' asides now that scope() honors itmeasures 101 characters, one overcommitlint.config.js'sheader-max-length: 100(line 27). Nothing enforces this on branch commits and the message never lands (squash-merge), so no action is required — flagging it only for the record, and as a reminder that the prefix counts toward the limit when authoring. - Sign-offs:
44b20820carriesSigned-off-by: Peter Amiri <petera@pai.com>matching the commit author; the earlier two commits carry matchingclaude[bot]sign-offs. The merge commit9da7dd22has none, which is conventional — merge commits are exempt from both commitlint and DCO sign-off practice.
Summary
scope()invendor/wheels/mapper/scoping.cfcdeclared nocallbackargument, so the documented.scope(path="/x", callback=function(map){ ... })form — andnamespace()/package()/controller(), which all forward toscope()— silently dropped it. The extra named argument landed inarguments, was prepended onto the scope stack, and was never invoked. Two failures resulted, both silent: (1) the callback never ran, so its routes were never registered (Wheels.RouteNotFound/ 404), and (2) the scope was never closed, so every route declared afterward (resources,root, evenwildcard) inherited the scope's path prefix and middleware — which is why misrouted URLs like/admin/postswould respond 200 while the intended public/posts404'd, making the breakage very hard to diagnose.This PR implements the issue's preferred direction:
scope()now consumescallbackexactly the waygroup()already does — it runscallback(this)to declare the nested routes and then auto-end()s the scope. Becausenamespace()/package()/controller()forward here, they all gain consistent callback support. The implementation reusesgroup()'s cross-engine-provenIsCustomFunction+callback(this)shape (single code path, no engine branching).Related Issue
Refs #3072
(Scoped to the framework fix + specs. The five guide MDX call sites the issue lists, and the
CLAUDE.mdnote, are handled separately bybot-update-docs.yml, so this usesRefsrather thanClosesto avoid auto-closing the tracking issue before the docs follow-up lands.)Type of Change
Feature Completeness Checklist
Signed-off-by:(git commit -s)vendor/wheels/tests/specs/mapper/MapperRobustnessSpec.cfcadds ascope()/namespace()/package() callback formblock: callback routes register under the scope path, the scope auto-closes (routes declared after are not swallowed), and scope middleware does not leak onto later routes. Written failing-first (5 fails reproducing the bug), then passing after the fix.bot-update-docs.ymlfollow-upbot-update-docs.ymlfollow-upbot-update-docs.ymlfollow-upchangelog.d/3072-scope-namespace-callback.fixed.mdTest Plan
Local Lucee 7 (7.0.1.100) + SQLite, via the running matrix server:
The key(s) [pattern] does not exist(callback never ran → no route registered)Expected [/public-index] but received [/limited/public-index](scope never closed → route after swallowed)The key(s) [middleware] does not exist(scoped route never created)MapperRobustnessSpec17/17 pass; the wholewheels.tests.specs.mapperdirectory plusmapperSpec(64) andmapperModernSpec(35) are green.Adobe 2018/2021/2023/2025 and BoxLang are exercised by the PR's compat-matrix CI. The change adds no engine-specific path — it mirrors
group()/api()/version(), whose callback handling already passes on every engine (thegroup()/api()/version()middleware specs in this same file).