Repository navigation
fix(router): except= normalization, scope option passthrough, end() underflow guard - #2925
Conversation
…nderflow guard Addresses three wave-2 review findings in the mapper (routing:1, routing:5, routing:11): - resources()/resource() only/except lists are now normalized (Trim + LCase per item) and except filtering uses array operations instead of a case-sensitive ReReplace, so except="Delete" or except="new, delete" can no longer silently leave excluded destructive routes registered. Unknown action names throw Wheels.InvalidResource when showErrorInformation is enabled (development) and are dropped otherwise. - namespace(), package(), and group() now forward all arguments to scope(), so options like middleware and binding are no longer silently dropped. api() and version() delegate to group() and inherit the fix. - end() now throws a clear Wheels.InvalidRoute error on scope-stack underflow (an extra end()) instead of a raw array-index engine error. Specs: vendor/wheels/tests/specs/mapper/MapperRobustnessSpec.cfc covers all three behaviors and fails against the pre-fix code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This PR correctly fixes three mapper bugs (case-sensitive except= filtering, silent argument-drop in namespace()/package()/group(), and an unguarded end() underflow). The implementations are sound, the cross-engine review passes all checks, and the new spec follows established test infrastructure patterns. Two minor nits below — neither blocks merge.
Correctness
The three fixes all check out:
R1 — except= normalization (vendor/wheels/mapper/resources.cfc:100-123):
The old code called LCase(arguments.only) on the whole string, so only="Index, Show" produced "index, show" — a list with a leading space on the second element — causing ListFind's exact-match to miss "show" and silently skip that route. The new $normalizeRestActions() trims each element individually and produces a clean lowercase list. Correct.
R5 — scope wrapper passthrough (vendor/wheels/mapper/scoping.cfc):
namespace() and package() now use scope(argumentCollection = arguments, $call = "namespace/package"). The explicit named $call argument overrides the argumentCollection correctly (CFML named args beat argumentCollection). group()'s forward loop excludes only callback, then forces $call = "group" below the loop — so even if a caller accidentally passes $call, the post-loop assignment always wins. Correct.
R11 — end() underflow guard (vendor/wheels/mapper/mapping.cfc:30-36):
$resetScopeStack() (Mapper.cfc:275-279) seeds exactly one root {"$call": "$draw"} entry. The normal flow pops it when the outer end() fires. The guard only triggers when the stack is genuinely empty. Recursive end() calls inside the resources processing path always find at least the root entry below them, so they are unaffected. Correct.
Minor concern — $normalizeRestActions() does not distinguish singular vs. plural resource:
vendor/wheels/mapper/resources.cfc:269: local.validActions = "index,new,create,show,edit,update,delete" includes index, but a singular resource() call has a default action set of new,create,show,edit,update,delete (no index). Passing only="index" to resource() (singular) produces 0 routes with no error in dev or prod mode — index passes the validation guard but the resource generator never emits an index route. This was already the case before this PR (the old LCase path passed it through silently too), so it is not a regression. But the new dev-mode validation misses this class of user error. A follow-up could scope validActions to the caller's default action set rather than the combined list.
Tests
Coverage gap — singular resource() path not exercised:
vendor/wheels/tests/specs/mapper/MapperRobustnessSpec.cfc tests all three normalization behaviors against resources() (plural). Both singular and plural call through to the same $normalizeRestActions() helper, so the code path is the same — but a single resource(name="profile", except="Delete") spec would serve as a regression guard if the dispatch ever diverges. Low-priority follow-up.
Shallow copy vs. deep copy in beforeAll():
MapperRobustnessSpec.cfc:16-18:
_originalStaticRoutes = StructKeyExists(application.wheels, "staticRoutes") ? StructCopy(
application.wheels.staticRoutes
) : {};_originalRoutes uses Duplicate() (deep copy) but _originalStaticRoutes uses StructCopy() (shallow copy). If a future spec were to mutate a nested struct inside an existing named-route entry, the shallow copy would fail to restore it. Not a problem for the current tests — just worth aligning to Duplicate for consistency.
application.wheels.showErrorInformation mutation pattern (lines 78-97):
Confirmed consistent with the established pattern in validationsSpec.cfc, primaryKeySpec.cfc, and paginationHelpersSpec.cfc. $get() reads from application[$appKey()]; application.$wheels and application.wheels are the same struct reference in a live app so setting either one is sufficient. No issue.
No local.X inside catch (BoxLang anti-pattern):
Both showErrorInformation restore blocks declare var originalSetting before try/finally, and the restore assignment in finally reads that pre-declared var — not a local.X write inside a catch body. Clean.
Cross-Engine
$normalizeRestActions()declaredpublicwith$prefix — mixin-integration invariant (CLAUDE.md #7) satisfied.- No closures, no
Left(str, 0), noattributeCollection = arguments, no bracket-notation calls inside closures anywhere in the changed files. - The
for (local.key in arguments) / StructKeyExists + !IsNullguard pattern ingroup()mirrors the BoxLang-safe loop already used inscope()'s BoxLang branch. - Inline closures passed as named args to
m.group(),m.api(),m.version()in the spec are method calls, notnew Component(callback = ...)constructor calls — Adobe CF anti-pattern #5 does not apply.
Commits
Single commit f134e956c:
- Header:
fix(router): except= normalization, scope option passthrough, end() underflow guard— 83 chars, valid type, unrestricted scope, not ALL-CAPS. - DCO:
Signed-off-by: Peter Amiri <peter@alurium.com>present. - Body explains the "why" (case-sensitivity left excluded destructive routes registered; silent arg-drop; raw engine error on underflow).
Docs
CHANGELOG.md omission explicitly deferred to campaign end — understood, not a blocker.
Wheels Bot -- Reviewer B (round 1)A's review is thorough and checks out against the actual diff. No sycophancy, no false positives, no missed issues of substance. I agree the PR is merge-ready with the two minor follow-up nits A flagged. SycophancyNone detected. A backed every claim with specific file+line citations and verified all three fixes against the diff before endorsing them. The COMMENTED state is correct per bot rails. False positivesNone detected. I re-read every cited code section:
Missed issuesNone detected. Re-scan findings:
A's two nits (singular Verdict alignmentCOMMENTED with "neither blocks merge" is consistent with the findings. Correct. ConvergenceAligned. A's analysis is accurate, the diff is clean, and the two open items are follow-up candidates not blockers. Joint recommendation: merge as-is. |
Summary
Fixes three mapper findings from the wave-2 framework review (package
mapper-resources-scoping):resources()/resource()only/exceptlists are now normalized (Trim + LCase per item, empties dropped) andexceptfiltering uses array operations instead of the old case-sensitiveReReplace("\b...\b"), soexcept="Delete"orexcept="new, delete"can no longer silently leave an excluded destructive route registered. Unknown action names throwWheels.InvalidResourcein development (showErrorInformation) and are dropped otherwise.namespace(),package(), andgroup()now forward all arguments toscope(), so options likemiddlewareandbindingare no longer silently dropped.api()andversion()already delegate togroup()and inherit the fix.end()now throws a clearWheels.InvalidRouteDSL error on scope-stack underflow (an extraend()) instead of a raw array-index engine error.Findings addressed
resource()/resources()except=filtering is case-sensitive and untrimmed; excluded destructive routes stay registered @vendor/wheels/mapper/resources.cfc:103-125(normalizedonly/except+ array-based filter) with new helper$normalizeRestActions()@vendor/wheels/mapper/resources.cfc:268. The fix sits in the sharedresource()path so both singular and plural are covered; the normalized list feeds the exact-matchListFindconsumers invendor/wheels/mapper/mapping.cfc.namespace()/package()/group()/api()/version()silently dropmiddleware,binding, and otherscope()options @vendor/wheels/mapper/scoping.cfc:113(namespace()),vendor/wheels/mapper/scoping.cfc:128(package()),vendor/wheels/mapper/scoping.cfc:172-183(group()copies every passed argument exceptcallback);api()/version()forwardargumentCollectiontogroup()and inherit the fix.end()has no scope-stack underflow guard @vendor/wheels/mapper/mapping.cfc:30-37(ArrayIsEmptyguard throwsWheels.InvalidRoute, a pre-existing error type, before anyscopeStack[1]access;$resetScopeStackseeds a root entry, so internal recursiveend()calls never hit an empty stack).Findings verified already-fixed
None — all three findings were verified live on
origin/developbefore fixing (the case-sensitiveLCase+ReReplaceblock inresources.cfc, the subset-forwardingnamespace()/package()/group()wrappers inscoping.cfc, and the unguardedscopeStack[1]access inmapping.cfcwere all still present).Source
Internal multi-agent framework review 2026-06-09, wave 2, package
mapper-resources-scoping.Tests
vendor/wheels/tests/specs/mapper/MapperRobustnessSpec.cfc— 12 tests covering all three behaviors (except/only normalization incl. casing + whitespace, dev-mode throw + prod-mode ignore for unknown actions, middleware/binding passthrough fornamespace()/package()/group()/api()/version(), and theend()underflow error). 11 of 12 fail against the pre-fix code; the prod-ignore test is a pass-pre-fix regression guard.testBundles=wheels.tests.specs.mapper.MapperRobustnessSpec): 12 pass, 0 fail, 0 error (HTTP 200).Cross-engine notes
$normalizeRestActions()ispublicwith a$prefix (mixin-integration invariant 7).Left(str, 0), noattributeCollection = argumentsin the framework changes;group()'s argument copy uses theStructKeyExists+IsNullguarded loop pattern (mirrorsscope()'s BoxLang branch).##correctly, contains no inline closures as constructor named args and nolocal.X-in-catch patterns, and follows theNestedResourcesSpecconstruction andmapperModernSpecapplication-route snapshot/restore precedents.Changelog
Entry deliberately omitted; consolidated at campaign end.
🤖 Generated with Claude Code