Skip to content

fix(view): paginationNav rejects unknown sub-helper arguments - #2726

Merged
bpamiri merged 5 commits into
developfrom
fix/bot-2717-paginationnav-silently-discards-unknown-arguments
May 16, 2026
Merged

bpamiri merged 5 commits into
developfrom
fix/bot-2717-paginationnav-silently-discards-unknown-arguments

Conversation

@wheels-bot

@wheels-bot wheels-bot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

paginationNav() built a passthrough struct of every argument it did not
recognize as its own and handed it to each sub-helper via
argumentCollection. CFML's dispatch silently drops keys that are not
declared in the receiving function's signature, so typos such as
prependToList="<ul>" had no effect and gave the caller no signal that
the argument was rejected. This PR adds validation: when
application.wheels.showErrorInformation is on, any unrecognized
pass-through argument now throws Wheels.PaginationNav.InvalidArgument
with a message listing both the rejected names and the full allowlist of
accepted keys. Production environments (showErrorInformation = false)
keep the silent-drop behavior so live traffic is unaffected.

Related Issue

Closes #2717

Type of Change

  • Bug fix
  • New feature
  • Enhancement to existing feature
  • Documentation update
  • Refactoring

Feature Completeness Checklist

  • DCO sign-off -- Every commit carries Signed-off-by: (use git commit -s)
  • Tests -- New specs in vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc covering the rejection (failing → passing), the documented-args happy path, and the production-mode silent-drop fallback
  • Framework Docs -- handled separately by bot-update-docs.yml
  • AI Reference Docs -- handled separately by bot-update-docs.yml
  • CLAUDE.md -- handled separately by bot-update-docs.yml
  • CHANGELOG.md -- entry added under [Unreleased] > Fixed
  • Test runner passes -- local Lucee 7 + SQLite run: 516 pass, 0 fail, 0 error for the view layer (was 515 pass, 1 fail before the implementation, confirming the TDD red→green cycle)

Test Plan

  1. Run the view layer specs against Lucee 7 + SQLite:
    bash tools/test-local.sh view
    Expect 516/516 passing.
  2. Manual reproduction in any controller view:
    #paginationNav(prependToList="<ul>")#
    In development (showErrorInformation = true) this now throws
    Wheels.PaginationNav.InvalidArgument; in production the call returns
    the rendered nav unchanged, matching the prior behavior.
  3. Spot-check the documented arguments still work:
    #paginationNav(windowSize=3, classForCurrent="active", class="page-link")#

paginationNav() built a passthrough struct of unrecognized arguments and
handed it to every sub-helper via argumentCollection. CFML's dispatch
silently drops keys that aren't declared in the receiving signature, so
typos like prependToList="<ul>" had no effect and gave the caller no
signal that the argument was rejected.

After this change, paginationNav() validates the passthrough struct
against the union of its sub-helpers' declared arguments (and the
URL-building keys forwarded by $paginationLinkToArgs). Any leftover keys
throw Wheels.PaginationNav.InvalidArgument when
application.wheels.showErrorInformation is on; the message lists both
the rejected names and the full allowlist. Production environments
(showErrorInformation = false) keep the silent-drop behavior so live
traffic is unaffected.

Fixes #2717

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

wheels-bot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

Wheels Bot — Docs updated

Added a doc commit to this PR:

  • CLAUDE.md — Added a note to the "Pagination View Helpers" section documenting that paginationNav() throws Wheels.PaginationNav.InvalidArgument for unknown arguments in development mode, and listing the accepted pass-through argument names.

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer A

TL;DR: The PR correctly diagnoses the silent-drop problem and the gating on showErrorInformation is the right production-safety call. However, the static allowlist in allowedSubArgs does not account for arbitrary route-segment variables (e.g., userId, slug) that callers legitimately pass to paginationNav() when using named routes with URL parameters. Those arguments will be falsely thrown as InvalidArgument in development mode, which is a functional regression for any application that paginates over named route URLs. Additionally, the validation is unreachable on single-page result sets because the early-return guard runs first. This review is submitted as a comment because GitHub does not permit self-reviews — the findings below are change requests in intent.


Correctness

C1 — Route-variable false positives (vendor/wheels/view/pagination.cfc, lines 357–368)

The allowlist is a static union of sub-helper parameter names plus the URL-building keys forwarded by $paginationLinkToArgs. But $paginationLinkToArgs has a second pass-through mechanism (lines 461–468) for route segment variables:

local.routeVars = ListToArray(local.routeConfig.foundvariables);
for (local.key in local.routeVars) {
    if (StructKeyExists(arguments.args, local.key) && local.key != arguments.name) {
        local.linkArgs[local.key] = arguments.args[local.key];
    }
}

A caller using a named route with segment variables must pass those variables to paginationNav():

##paginationNav(route="userTimeline", userId=user.id)##

userId ends up in arguments, is not in skipArgs, is not in allowedSubArgs, and therefore gets appended to local.unknownArgs. The validation then throws Wheels.PaginationNav.InvalidArgument even though the call is correct. This is a regression for any application that uses route-based pagination with URL segment parameters.

Suggested fix — before deciding to throw, filter out keys that appear in the route's foundvariables:

if (Len(local.unknownArgs) && StructKeyExists(local.subArgs, "route") && Len(local.subArgs.route)) {
    local.routeVarList = $findRoute(argumentCollection = local.subArgs).foundvariables;
    local.filteredUnknown = "";
    for (local.uk in ListToArray(local.unknownArgs)) {
        if (!ListFindNoCase(local.routeVarList, local.uk)) {
            local.filteredUnknown = ListAppend(local.filteredUnknown, local.uk);
        }
    }
    local.unknownArgs = local.filteredUnknown;
}
if (Len(local.unknownArgs) && application.wheels.showErrorInformation) {
    Throw(...);
}

C2 — Validation is unreachable for single-page (or empty) results (vendor/wheels/view/pagination.cfc, lines 342–375)

The unknown-arg check runs after the early return:

local.pg = pagination(arguments.handle);

if (local.pg.totalPages <= 1 && !arguments.showSinglePage) {
    return "";        // returns here; unknownArgs loop never runs
}

// ... allowedSubArgs build, unknownArgs check ...

A developer who passes a misspelled argument against a table with fewer records than perPage will never see the InvalidArgument error, defeating the purpose of developer-mode validation. The argument loop should be hoisted above the early return so the check fires regardless of page count.


Tests

T1 — No test for named-route segment variables (vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc)

The three new specs cover the typo case and the production silent-drop fallback, but there is no test for passing a legitimate route variable alongside route=:

paginationNav(route="someNamedRoute", userId=someUser.id)

A test asserting this does not throw would directly catch the regression in C1 and would have blocked the merge. This test is required alongside the fix for C1.

T2 — Minor: _origShowErr written to variables scope without var (lines 284, 296, 313)

An unscoped assignment inside an it() closure writes to the enclosing CFC's shared variables scope. The try/finally block ensures the value is always restored, so no real failure results in synchronous runs, and the existing tests in this file follow the same pattern. Noting for awareness rather than requiring a change.


Docs

The PR checklist marks framework and AI reference docs as handled separately by bot-update-docs.yml. Acceptable. The CHANGELOG entry under [Unreleased] > Fixed accurately describes the change and is consistent in verbosity with the adjacent entries.


Commits

Single commit fix(view): paginationNav rejects unknown sub-helper arguments — type fix, clear subject within 100 chars, Signed-off-by trailer present. Commit message correctly explains the why (CFML's argumentCollection silent-drop). DCO satisfied.


Security

No concerns. The interpolated unknown-arg names in the error message are only reachable when showErrorInformation = true (development mode), where error output is already exposed to developers.

@wheels-bot

wheels-bot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

Wheels Bot — Reviewer B (round 1)

A's review is accurate. Both correctness findings (C1 and C2) are confirmed against the actual code, the suggested fixes are sound, and the verdict aligns with the findings. No sycophancy, no false positives, no missed issues.

One housekeeping note: A's review marker cites SHA 6c37756a8... (the fix commit), while the current head is a652ddbe7... (a docs-only CLAUDE.md update on top). The intervening commit touches only CLAUDE.md — pagination.cfc is identical — so A's analysis of the code stands without qualification.

Sycophancy

None detected. A opened with a fair positive framing of the overall approach, but immediately raised two substantive correctness issues that are change-blocking. No unsubstantiated LGTM.

False positives

None detected.

  • C1 verified: local.allowedSubArgs in pagination.cfc lines 357-366 is a static list (format,text,name,class,...,route,controller,action,key,...). Arbitrary route segment variables (e.g., userId, slug) are not in it. $paginationLinkToArgs (lines 462-473) does iterate local.routeConfig.foundvariables to forward those variables to linkTo(), so callers must pass them to paginationNav(), and the current implementation would throw on them in dev mode. The regression is real.

  • C2 verified: the early return is at line 342 (if (local.pg.totalPages <= 1 && !arguments.showSinglePage) { return ""; }); the unknownArgs throw is at line 369. The guard precedes the validation, so a dev working against a small dataset will never see the InvalidArgument error. The gap is real.

  • T1 verified: the three added specs do not exercise paginationNav(route="...", userId=someId).

  • T2 accurate: _origShowErr is unscoped in closures (writes to variables scope), but try/finally restores it and the rest of the file follows the same pattern. Informational-only call is appropriate.

Missed issues

None that warrant blocking. One minor observation A did not call out: the local.unknownArgs list is interpolated directly into the error message in the Throw() call. In dev mode this is fine — argument key names are author-supplied CFML identifiers, not user-controlled input. No security concern.

Verdict alignment

A stated "change requests in intent" and listed two correctness bugs plus a test gap. That is consistent with a request-changes verdict. Correct.

Convergence

Aligned. Both C1 (route-variable false positives) and C2 (validation bypassed by early return) are confirmed and need to be addressed before this merges, along with the T1 test for the C1 fix. A's suggested approach for each is sound. Emitting converged-changes — bot-address-review.yml should pick up the consensus and apply the fixes.

- C1: filter out named-route segment variables (forwarded by
  $paginationLinkToArgs at link-build time) from the unknown-args list
  before throwing, so paginationNav(route="userTimeline", userId=...)
  is no longer a false-positive InvalidArgument
  (vendor/wheels/view/pagination.cfc)
- C2: hoist the unknown-args validation above the totalPages early
  return so the check still fires on single-page (or empty) result
  sets — previously a typo against a small dataset was invisible
  (vendor/wheels/view/pagination.cfc)
- T1: add a regression spec covering paginationNav(route=..., <segmentVar>=...)
  with showErrorInformation=true; verifies the C1 fix and would have
  caught the regression
  (vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc)

Verified locally against Lucee 7 + SQLite: view layer 517 pass / 0 fail
(was 516 before this commit; the new T1 spec adds one).

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

wheels-bot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

Wheels Bot — Address Review (round 1)

Applied consensus findings from Reviewer A and Reviewer B's convergence (round 1):

  • C1 — Route-variable false positives: paginationNav() now calls $findRoute() when route= is set and filters keys in the route's foundvariables out of local.unknownArgs before throwing. paginationNav(route="userTimeline", userId=user.id) no longer trips a false-positive InvalidArgument. (vendor/wheels/view/pagination.cfc:346-396)
  • C2 — Validation unreachable for single-page results: Hoisted the unknown-args validation above the totalPages <= 1 early return so the check fires regardless of page count. A typo against a small dataset is no longer invisible. (vendor/wheels/view/pagination.cfc:346-396)
  • T1 — Regression spec for named-route segment variables: Added it("does not throw when passing a named-route segment variable", ...) to paginationHelpersSpec.cfc. The spec mounts a custom userTimeline route via g.mapper().$match(...), calls paginationNav(route="userTimeline", userId=42) with showErrorInformation=true, and asserts no throw. Also added the required $clearRoutes() helper (CLAUDE.md noted it isn't inherited from wheels.WheelsTest). (vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc:312-338, :344-348)

Skipped findings (not in the consensus to act on):

  • T2 — _origShowErr unscoped in closures was informational-only in A's review and B confirmed; try/finally already restores the value and the rest of the file uses the same pattern. No code change required.

Local test results (Lucee 7 + SQLite, view layer):

517 pass, 0 fail, 0 error

The view layer was 516/0/0 before this commit; the new T1 spec adds one. paginationHelpersSpec is 33/33.

The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on `approve` or the outer-loop cap (5 rounds) is reached.

@bpamiri
bpamiri marked this pull request as ready for review May 15, 2026 19:46

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer A

TL;DR: Round 1 fixes correctly address both consensus findings from the prior review cycle. C1 (route-variable false positives) is resolved via the $findRoute filter, C2 (validation unreachable on single-page result sets) is resolved by hoisting the check above the early return, and the missing regression test (T1) is now present and well-structured. No new correctness, cross-engine, or security issues were introduced. Submitting as a comment; findings below are nits only.


Correctness

C1 resolved — route-variable filter is correct (vendor/wheels/view/pagination.cfc, lines 368-376)

The new filter correctly calls $findRoute(argumentCollection = local.subArgs) using the already-built local.subArgs struct (which contains route and all route-segment variables) to retrieve foundvariables, then removes matching entries from local.unknownArgs before the throw decision. Verified against $findRoute's implementation in Global.cfc (open-args signature, uses only arguments.route and arguments.method): extra keys like handle and encode that land in local.subArgs are silently ignored. Logic is sound.

C2 resolved — early return hoisted correctly (vendor/wheels/view/pagination.cfc, lines 388-393)

local.pg = pagination(arguments.handle) and the totalPages <= 1 guard now run after the full validation block. A one-page result set in development mode will now surface the InvalidArgument error as intended.

Minor: $findRoute is called regardless of showErrorInformation (vendor/wheels/view/pagination.cfc, lines 368-376)

The route-variable filter is entered whenever Len(local.unknownArgs) && ... route ... is true, with no showErrorInformation gate. In production (showErrorInformation = false), this causes a $findRoute lookup that ultimately makes no difference (the final Throw() is still gated). The wasted work is negligible. The behavioral side-effect is also negligible: a non-existent route name in production was already a 500 waiting to happen at link-build time; it now surfaces slightly earlier. Not a blocker, but gating the filter on showErrorInformation would make the production path cleaner:

// Proposed tightening (optional)
if (Len(local.unknownArgs) && application.wheels.showErrorInformation) {
    if (StructKeyExists(local.subArgs, "route") && Len(local.subArgs.route)) {
        // filter route vars before throwing
        ...
    }
    if (Len(local.unknownArgs)) {
        Throw(...);
    }
}

Tests

T1 resolved -- route-segment regression test is well-structured (vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc, lines 324-349)

The test correctly:

  • Backs up and restores application.wheels.routes, staticRoutes, namedRoutePositions, URLRewriting, and showErrorInformation in a finally block.
  • Calls the local $clearRoutes() helper (correctly defined at line 357 per the CLAUDE.md pattern from linksSpec.cfc).
  • Registers a userTimeline route with pattern "users/[userId]/timeline" so that $stripRouteVariables produces foundvariables = "userId".
  • Calls g.$setNamedRoutePositions() to rebuild the name-to-position index after the route is registered.
  • Asserts notToThrow(), which is the minimal correct assertion for this regression.

One nit: all the _orig* save variables (_origShowErr, _origRoutes, _origStaticRoutes, _origNamedRoutePositions, _origRewrite) are assigned without var, writing to the enclosing CFC's variables scope. This is consistent with the existing pattern in this file (acknowledged as pre-existing in T2 of the prior round) and is safe for synchronous test runs. Flagging for awareness only.


Docs

CLAUDE.md update accurate, but missing route-variable nuance (CLAUDE.md, pagination section)

The new sentence correctly lists all 22 accepted pass-through keys and notes the showErrorInformation gate. However, it does not mention that named route segment variables (e.g., userId in paginationNav(route="userTimeline", userId=user.id)) are NOT considered unknown arguments. A developer reading only the CLAUDE.md note could reasonably conclude that userId would throw since it is absent from the listed keys. Suggested addition:

Named route segment variables (e.g., userId when route="userTimeline" and the pattern contains [userId]) are automatically exempted from this check and will not throw.

The CHANGELOG entry accurately describes the shipped behavior and is consistent in verbosity with adjacent entries.


Commits

Three commits on this branch:

SHA Message
6c37756a8 fix(view): paginationNav rejects unknown sub-helper arguments
a652ddbe7 docs: note paginationNav argument validation in CLAUDE.md
8d82bc6eb fix(view): address Reviewer A/B consensus findings (round 1)

All conform to commitlint: valid types (fix, docs), scope view is apt, subjects are sentence-case and under 100 characters. Signed-off-by: trailers present (DCO satisfied). No concerns.


Security

No concerns. The local.unknownArgs value interpolated into the error message consists of CFML argument names from the developer-authored template, not user-supplied HTTP input. The error is only reachable in development mode (showErrorInformation = true). No XSS risk in production.

@wheels-bot

wheels-bot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

Wheels Bot — Reviewer B (round 1)

A's review is accurate and the verdict is appropriate. All findings are verified against the diff. No sycophancy, no false positives, no missed issues of substance. Emitting converged-approve.

Sycophancy

None detected. A opens with a positive TL;DR but immediately identifies a genuine code-path concern (the unconditional $findRoute call in production) and a docs gap. Neither finding is handwaved away.

False positives

None detected.

  • C1/C2 resolved claims: Verified in the diff. The $findRoute filter is present and correct. The early-return hoist is confirmed — local.pg = pagination(arguments.handle) and the totalPages <= 1 guard now appear after the full validation block.
  • Minor: $findRoute unconditional in production: Verified. The outer if (Len(local.unknownArgs) && StructKeyExists(local.subArgs, "route") && Len(local.subArgs.route)) block has no showErrorInformation gate; the Throw() inside is gated but the route lookup is not. A's analysis is accurate. A labels it optional — appropriate, given the wasted work is a single route-table lookup on an already-malformed call, and the behavioral side-effect (earlier 500 for nonexistent routes) is negligible.
  • $findRoute "open-args" characterization: A says it uses only arguments.route and arguments.method. The actual implementation also reads arguments[variable] for each name in the route's foundvariables list. This imprecision does not affect the correctness analysis — extra keys in local.subArgs land in arguments and are never accessed by $findRoute unless they happen to share a name with a route segment variable, which is exactly the scenario the filter is designed to handle. No false positive.
  • T1 test structure claims: Verified. $clearRoutes() is defined at the bottom of the CFC, g.$setNamedRoutePositions() is called after route registration, state is fully restored in finally. All correct.
  • _orig* unscoped: Verified — none of the save variables carry a var keyword. A correctly identifies these as writing to variables scope and correctly notes the pre-existing pattern and safe try/finally restoration. Informational-only call is right.
  • CLAUDE.md docs gap: A's suggested addition (noting that named route segment variables are exempt from the unknown-arg check) is accurate and genuinely useful for a developer reading the entry in isolation.

Missed issues

None that warrant blocking. One minor observation A did not raise: _origStaticRoutes is saved with StructCopy (shallow) rather than Duplicate (deep), inconsistent with _origRoutes = Duplicate(...). For the Wheels route state shape this is likely safe since staticRoutes is typically a flat name-to-position struct, but worth noting if this test becomes flaky. Not blocking.

Verdict alignment

A submitted as a comment with findings labeled "nits only." Given that C1 and C2 are confirmed resolved, the remaining items are a code-quality suggestion (optional showErrorInformation gate on $findRoute), a pre-existing test pattern nit, and a one-sentence docs improvement. A comment verdict is consistent with the findings.

Convergence

Aligned. The implementation is correct, the regression test is present, and no new issues were introduced. A's optional suggestion and docs note are reasonable improvements but not blockers. Joint recommendation: approve.

…mation

Round 2 polish from Reviewer A: the $findRoute filter was entered
whenever local.unknownArgs was non-empty and a route was present, even
in production where the final throw is gated. Nest both the route-var
filter and the throw inside a single application.wheels.showErrorInformation
guard so the production path skips the $findRoute lookup entirely. Behavior
in dev mode is unchanged — same filter, same throw, same allowlist.

Also documents the route-variable exemption in CLAUDE.md so a reader of
the pagination section understands that named route segment variables
are auto-exempted from the unknown-arg check.

Local verification on Lucee 7 + SQLite (this worktree):
  view layer:       517 pass, 0 fail, 0 error
  security layer:   174 pass, 0 fail, 0 error
  full core suite: 3557 pass, 0 fail, 0 error

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

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer A

TL;DR: Round 2 polish commit (56c95b4ca) addresses the one open nit from the previous review cycle — gating the $findRoute call inside the showErrorInformation block so the production path skips both the lookup and the throw entirely. The restructuring is correct, the CLAUDE.md doc is now complete, and no new issues were introduced. No findings — this diff is clean.


Correctness

Round 2 nit resolved — $findRoute is now gated on showErrorInformation (vendor/wheels/view/pagination.cfc, lines 366–388)

The previous round's nit noted that the route-var filter entered $findRoute whenever local.unknownArgs was non-empty and a route was present, even in production where the final throw was already gated. The fix nests both the filter and the throw inside the outer showErrorInformation guard:

if (Len(local.unknownArgs) && application.wheels.showErrorInformation) {
    if (StructKeyExists(local.subArgs, "route") && Len(local.subArgs.route)) {
        local.routeVarList = $findRoute(argumentCollection = local.subArgs).foundvariables;
        // ... filter local.unknownArgs ...
    }
    if (Len(local.unknownArgs)) {
        Throw(type = "Wheels.PaginationNav.InvalidArgument", ...);
    }
}

Production path: local.unknownArgs is built by the for loop (negligible — the loop runs anyway to populate local.subArgs), but neither $findRoute nor Throw are invoked. Dev path: same filter, same throw, no behavioral change. Logic is sound.

One observation worth noting but not worth holding: local.unknownArgs is accumulated in the for loop unconditionally (outside the showErrorInformation guard), so there is a small string-building cost per request even in production. Given the loop already iterates over arguments to build local.subArgs, this is genuinely negligible. Not a blocker.


Docs

CLAUDE.md updated with route-variable exemption (CLAUDE.md, Pagination View Helpers section)

The paragraph now includes the suggested sentence:

Named route segment variables (e.g., userId when route="userTimeline" and the pattern contains [userId]) are automatically exempted from this check. In production the unknown argument is silently dropped.

This closes the documentation gap flagged in round 2 — a developer reading only CLAUDE.md can now understand that paginationNav(route="userTimeline", userId=user.id) is valid and will not throw.


Commits

Four commits total on this branch:

SHA Message
6c37756a8 fix(view): paginationNav rejects unknown sub-helper arguments
a652ddbe7 docs: note paginationNav argument validation in CLAUDE.md
8d82bc6eb fix(view): address Reviewer A/B consensus findings (round 1)
56c95b4ca refactor(view): gate paginationNav route-var lookup on showErrorInformation

All conform to commitlint: valid types (fix, docs, refactor), scope view is apt throughout, subjects are sentence-case and under 100 characters. Signed-off-by: trailers present on all commits (DCO satisfied). The round 2 commit message correctly names the Reviewer A nit it addresses and describes the behavioral invariant (same filter, same throw, same allowlist in dev mode). No concerns.


Security

No concerns. The interpolated local.unknownArgs value in the error message is composed of CFML argument names from developer-authored templates, not user-supplied HTTP input. The error is only reachable under showErrorInformation = true (development mode). Production is unaffected.

@wheels-bot

wheels-bot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

Wheels Bot — Reviewer B (round 1)

A's review of 56c95b4ca is accurate and well-substantiated. The $findRoute gating fix is confirmed, CLAUDE.md is complete, and the test additions are correct. No sycophancy, no false positives, no missed issues. Emitting converged-approve.

One procedural note: A submitted as a COMMENTED review rather than APPROVED despite finding no blockers. This does not change the substance of the review but means the GitHub PR carries no formal approval signal from this round. A human reviewer will need to click Approve before merge — this is expected given the PR is still in draft state.

Sycophancy

None detected. A opens with a positive TL;DR but supports every claim with specific lines from the diff (lines 366-388 for the guard restructure, the CLAUDE.md hunk, the commit table). No bare LGTM.

False positives

None detected.

  • $findRoute gating claim: Verified in the diff. The outer if (Len(local.unknownArgs) && application.wheels.showErrorInformation) wraps both the route-filter block and the Throw(), so neither executes in production. A's description matches the code exactly.
  • Production path observation (unconditional local.unknownArgs accumulation): Verified. The for loop that populates local.unknownArgs is outside the showErrorInformation guard, so the string-building cost is incurred per request in production. A correctly labels this negligible -- the loop already iterates to build local.subArgs, so the extra ListAppend is truly marginal.
  • CLAUDE.md update: Verified. The diff shows the exemption sentence added in the Pagination View Helpers section.
  • Commits table: Verified against git log. All four commits have valid commitlint types (fix, docs, refactor), view scope throughout, sentence-case subjects under 100 chars. DCO confirmed as uncontested in prior rounds.

Missed issues

None that warrant blocking. One pre-existing nit already on record from B's round 1 on SHA 8d82bc6eb (not introduced by this commit): _origStaticRoutes is saved with StructCopy (shallow) while _origRoutes uses Duplicate (deep). Safe for the flat name-to-position struct shape of staticRoutes; flagged as a potential flakiness source if the structure ever deepens. Not a new miss by A on this SHA.

No cross-engine concerns: ListFindNoCase, ListAppend, ListToArray, StructKeyExists, the for-in-arguments loop, and argumentCollection are all portable across Lucee, Adobe CF, and BoxLang.

Verdict alignment

A filed as COMMENTED with the stated verdict "no findings -- this diff is clean." A COMMENTED submission when findings are absent is a minor process inconsistency (an APPROVED submission would give GitHub the formal signal), but the textual verdict and the findings are internally consistent: A lists no blockers and recommends no changes.

Convergence

Aligned. The implementation is correct -- $findRoute is properly gated, validation is hoisted above the early-return, the T1 regression spec is present with correct route-state restoration and a properly defined $clearRoutes(). All prior consensus findings are addressed. Joint recommendation: approve.

…ationnav-silently-discards-unknown-arguments

# Conflicts:
#	CHANGELOG.md

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer A

TL;DR: The new head is a merge commit (739651664) that pulls origin/develop into the PR branch and resolves a single CHANGELOG conflict. No files touched by the PR's own implementation (vendor/wheels/view/pagination.cfc, vendor/wheels/tests/specs/view/paginationHelpersSpec.cfc, CLAUDE.md) were changed by the merge. The CHANGELOG resolution is correct — both the paginationNav entry (from this branch) and the develop entries are present in the final file. No new correctness, cross-engine, security, or commit-style issues were introduced. Submitting as a comment; GitHub blocks self-review approvals since wheels-bot is also the PR author.


Correctness

Merge conflict resolution is accurate (CHANGELOG.md, [Unreleased] > Fixed section)

The diff --cc shows the paginationNav entry (authored on this branch) was added as the first item under Fixed, with the develop-side entries following it in the correct order. No entries were dropped or duplicated. Resolution is clean.

No other files changed in the merge commit. The implementation in pagination.cfc, the four specs in paginationHelpersSpec.cfc, and the CLAUDE.md update all carry over unchanged from commits 6c37756-56c95b4 (reviewed in prior rounds).

One standing observation (not a blocker, noted in round 3): if a caller simultaneously passes a mistyped route name and an unknown argument, the showErrorInformation block enters $findRoute and throws Wheels.RouteNotFound before it can reach the Wheels.PaginationNav.InvalidArgument throw. This edge case existed before this PR (the same $findRoute call fires at link-build time in production) and the developer-facing error is still actionable. No new regression.


Commits

Five commits on the branch at this head:

SHA Author Message
6c37756 claude[bot] fix(view): paginationNav rejects unknown sub-helper arguments
a652ddbe claude[bot] docs: note paginationNav argument validation in CLAUDE.md
8d82bc6e claude[bot] fix(view): address Reviewer A/B consensus findings (round 1)
56c95b4c Peter Amiri refactor(view): gate paginationNav route-var lookup on showErrorInformation
73965166 Peter Amiri Merge origin/develop into branch

All bot-authored commits carry Signed-off-by: claude[bot] trailers; the human-authored 56c95b4c carries Signed-off-by: Peter Amiri. The merge commit is a standard Merge ... commit; the DCO GitHub App exempts merge commits from the trailer requirement. DCO satisfied across all five commits. All conventional-commit subjects conform to commitlint (valid types, sentence-case, under 100 chars).


Security

No concerns. The interpolated local.unknownArgs value in the error message is composed of CFML argument names from developer-authored templates, not user-supplied HTTP input. The error path is gated on application.wheels.showErrorInformation = true (development mode). Production is unaffected.

@wheels-bot

wheels-bot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

Wheels Bot — Reviewer B (round 1)

A's core conclusion (no implementation blockers) is correct, but A made a factual error in characterizing the merge commit scope that should be on record. One false positive, no missed blocking issues, verdict alignment is appropriate. Converging approve.

Sycophancy

None detected. A opened with a positive TL;DR but immediately qualified it by noting one standing edge-case observation and correctly explaining the self-review restriction. No bare LGTM.

False positives

One, notable.

  • Merge commit scope claim: A stated "No other files changed in the merge commit." The GitHub API for commit 7396516643a8259e09a7e5f17f507ee471e8853c lists 24 changed files, not 1. Beyond CHANGELOG.md (the conflict A reviewed), the merge brought in: 9 deploy CLI files (cli/lucli/services/deploy/cli/*.cfc, cli/lucli/Module.cfc), 2 deploy library files (FakeSshPool.cfc, SshClient.cfc), 2 deploy test files (DeployMainCliSpec.cfc, FakeSshPoolSpec.cfc), a newly added vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc, 6 guide doc files, and a visual baseline binary. A appears to have read only the diff --cc conflict hunks (CHANGELOG only) rather than the full merge commit diff, leading to the incorrect scope description.

    The most noteworthy of the additional files is vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc (status: added, +70 lines). CLAUDE.md warns that any compilation error in a spec file under vendor/wheels/tests/specs/ crashes the entire test suite for that engine. A should have at least acknowledged this file. That said, it arrived from develop where it presumably passed CI, so the risk is low in practice.

    A's downstream claim — that the paginationNav implementation files (pagination.cfc, paginationHelpersSpec.cfc, CLAUDE.md) were not changed by the merge — is accurate and verifiable from the file list. The factual error is in the scope description, not the implementation conclusion.

Missed issues

None that warrant blocking. The 23 develop-side files the merge introduced (deploy CLI refactors, docs, visual baseline) are unrelated to this PR's implementation and don't conflict with the paginationNav changes. The standing edge-case about $findRoute throwing Wheels.RouteNotFound before Wheels.PaginationNav.InvalidArgument when both a mistyped route name and an unknown argument are present is accurately characterized as pre-existing and non-blocking.

Verdict alignment

A submitted as a COMMENT with no blockers identified, and correctly explained that GitHub prevents self-review approvals. Consistent with the findings.

Convergence

Aligned. A's false positive is a scope description error, not an implementation quality error — correcting it does not change the verdict. Even with the full 24-file scope understood, the paginationNav implementation files are unchanged, all prior consensus findings (C1 route-variable false positives, C2 validation-before-early-return, T1 regression spec) were addressed and confirmed in earlier rounds, and no new issues were introduced by the merge. Joint recommendation: approve.

@bpamiri
bpamiri merged commit ce47c80 into develop May 16, 2026
6 checks passed
@bpamiri
bpamiri deleted the fix/bot-2717-paginationnav-silently-discards-unknown-arguments branch May 16, 2026 01:43
bpamiri added a commit that referenced this pull request May 16, 2026
…keys

Reviewer B (round 1) round 5 noted that the `Accepted pass-through keys` list in
the Pagination View Helpers section of CLAUDE.md omits `addActiveClassToPrependedParent`,
even though `local.allowedSubArgs` in `paginationNav()` already accepts it (the merge
with #2726 added it to the allowlist). Without this, a developer scanning only the
list would conclude the keyword fails validation when in fact it passes through to
`pageNumberLinks()`. One-word addition to keep the list aligned with the code.

Signed-off-by: Peter Amiri <peter@alurium.com>
bpamiri added a commit that referenced this pull request May 16, 2026
…er-anchor wrappers (#2730)

* fix(view): paginationNav now accepts Bootstrap-style prepend/append/per-anchor wrappers

paginationNav() gains prepend / append (outer wrappers inside <nav>),
prependToPage / appendToPage (per-anchor wrappers that now apply to
first / previous / next / last as well as the numbered links),
addActiveClassToPrependedParent (injects "active " into the current page's
prependToPage class= attribute), and anchorDivider. pageNumberLinks() also
gains addActiveClassToPrependedParent so the same idiom flows through when
helpers are composed by hand. Bootstrap-styled 3.x apps can now do a
like-for-like swap from paginationLinks() to paginationNav().

Fixes #2715

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

* docs(web/guides): document paginationNav Bootstrap args and update CLAUDE.md

Add Bootstrap 5 like-for-like swap example to the 3x-to-4x upgrade guide's
paginationLinks() section, and update CLAUDE.md Pagination View Helpers with
the new prepend/append/prependToPage/appendToPage/addActiveClassToPrependedParent/
anchorDivider args added in #2715.

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

* fix(view): scrub event handlers from paginationNav prependToPage/appendToPage

paginationNav() is marketed as the canonical migration target for
paginationLinks(), but the new $paginationWrapAnchor() helper (added in
#2730) returned arguments.prependToPage & anchor & arguments.appendToPage
verbatim, so a user who swapped paginationLinks() for paginationNav()
silently lost the entity-decode + on\w+= / javascript: scrub that
paginationLinks() applies in vendor/wheels/view/links.cfc. The numbered
page wrappers in pageNumberLinks() had the same gap.

Centralise the scrub in $paginationSanitizeWrapper() (decode HTML
numeric entities first so &##111;nmouseover-style payloads cannot
bypass the regex pass, then strip event-handler attributes and
javascript: URIs). Call it from $paginationWrapAnchor() for the
first/prev/next/last anchors and from the top of pageNumberLinks() so
the numbered-page loop is covered too. The helper mirrors the parallel
scrub in links.cfc so both surfaces share one audit point.

Strengthen the anchorDivider spec from expect(toInclude("|")), which
could match a stray | anywhere in a URL or page-number text, to a
sentinel ("XDIVX") plus a placement assertion that it must appear
between a closing tag and the next opening tag.

Note in the doc-comment for @addActiveClassToPrependedParent on both
pageNumberLinks() and paginationNav() that the option no-ops when
prependToPage contains no class attribute.

Add six new specs to PaginationXssSpec covering paginationNav with:
decimal/hex entity-encoded onmouseover in prependToPage, entity-encoded
javascript: URI, entity-encoded onmouseover in appendToPage, plain
onmouseover without encoding, and a benign class/id passthrough.

Local results (Lucee 7 + SQLite):
  paginationHelpersSpec: pass=34 fail=0 error=0
  security suite: pass=180 fail=0 error=0
  controller suite: pass=423 fail=0 error=0
  view suite: pass=518 fail=0 error=0

Addresses Reviewer A and Reviewer B findings on #2730.

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

* refactor(view): hoist paginationNav XSS scrub and document unsanitized prepend/append

Reviewer A non-blocking nits on PR #2730 head SHA 3c504bb:

1. `$paginationSanitizeWrapper()` was called up to four times on the same input from
   `paginationNav()`'s four `$paginationWrapAnchor()` sites. Hoist the scrub to the top of
   `paginationNav()`, overwrite `arguments.prependToPage` / `arguments.appendToPage` with the
   sanitized values once, and strip the per-call scrub from `$paginationWrapAnchor()` (which is
   internal and only invoked from here). Single audit point now lives at the `paginationNav()`
   entry. `pageNumberLinks()` keeps its own top-of-function scrub for defense-in-depth on direct
   callers; the redundant idempotent pass when invoked through `paginationNav()` is acceptable.

2. The asymmetry between sanitized `prependToPage`/`appendToPage` and unsanitized `prepend`/
   `append` was intentional but undocumented. Add an inline comment at the content-assembly site
   in `paginationNav()` spelling out the rationale (developer-authored structural markup vs.
   theme-author / CMS per-page extension point) so a future contributor doesn't conclude the
   protection contract applies uniformly.

3. The `@addActiveClassToPrependedParent` doc-tag on `paginationNav()` already said it forwarded
   to `pageNumberLinks`, but didn't make explicit that first / previous / next / last anchors
   never receive the `active ` injection (they are never "current" in the Bootstrap sense). Add
   that clarification.

4. Add two direct `pageNumberLinks(prependToPage = ...)` XSS specs to PaginationXssSpec — an
   entity-encoded handler and a plain handler — so the CHANGELOG claim that `pageNumberLinks()`
   itself scrubs has a direct regression anchor independent of `paginationNav()` coverage.

Local results (Lucee 7 + SQLite):
  security suite: pass=182 fail=0 error=0  (was 180 — +2 pageNumberLinks direct specs)
  view suite:     pass=518 fail=0 error=0  (unchanged)

No behavior change for any user-facing API. Sanitization continues to apply to both code paths;
the scrub site just moves to a single location. Direct callers of `$paginationWrapAnchor()` (an
internal `$`-prefixed helper) MUST pre-sanitize per the updated doc-comment.

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

* docs: list addActiveClassToPrependedParent in CLAUDE.md pass-through keys

Reviewer B (round 1) round 5 noted that the `Accepted pass-through keys` list in
the Pagination View Helpers section of CLAUDE.md omits `addActiveClassToPrependedParent`,
even though `local.allowedSubArgs` in `paginationNav()` already accepts it (the merge
with #2726 added it to the allowlist). Without this, a developer scanning only the
list would conclude the keyword fails validation when in fact it passes through to
`pageNumberLinks()`. One-word addition to keep the list aligned with the code.

Signed-off-by: Peter Amiri <peter@alurium.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>
Signed-off-by: Peter Amiri <peter@alurium.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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

paginationNav() silently discards unknown arguments

1 participant