Skip to content

TESTBOX-448 MockBox $args() relies on unordered struct order - #194

Closed
zspitzer wants to merge 4 commits into
Ortus-Solutions:developmentfrom
zspitzer:TESTBOX-448-struct-arg-matching
Closed

TESTBOX-448 MockBox $args() relies on unordered struct order#194
zspitzer wants to merge 4 commits into
Ortus-Solutions:developmentfrom
zspitzer:TESTBOX-448-struct-arg-matching

Conversation

@zspitzer

@zspitzer zspitzer commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • TESTBOX-448$args() arg matching fails for nested struct arguments because normalizeArguments() falls through to struct.toString(), whose output depends on HashMap iteration order. Two structurally-equal structs built in different insertion order hash differently, so the mock silently returns null.
  • Fix: introduces a private normalizeValue() helper that walks struct/array values recursively, sorting struct keys via java.util.TreeMap so iteration order can't affect the hash. Nested CFCs are serialized via metadata to match the top-level-arg behaviour and avoid Adobe's serializeJSON cycle on component metadata.
  • Surfaced by Lucee 7.1 (LDEV-5098), which replaced ConcurrentHashMapNullSupport with a thinner wrapper over java.util.concurrent.ConcurrentHashMap. Different bucket layout = different iteration order = latent fragility becomes consistently visible. Reproduces deterministically on Lucee 7.0.3.43 too (using structNew("ordered") to force insertion order).

Details

normalizeArguments() at system/MockBox.cfc sorts top-level arg keys via TreeMap, but for non-simple values it fell through to argOrderedTree[ arg ].toString(). That's Java's HashMap.toString() for a struct — output is not stable across iteration orders. Regular structs have never guaranteed iteration order in CFML, so this was always latent; it rarely hit in practice because tests usually build setup and call-site structs the same way. LDEV-5098 just made the fragility consistently visible.

The new normalizeValue() handles:

  • Simple values → toString() (unchanged fast path, preserves the integer ++/-- workaround).
  • CFCs → metadata via serializeJSON( getMetadata( value ) ) (mirrors the top-level-arg branch; must run before the struct branch because on Adobe CFCs are both isStruct and isObject).
  • Structs → TreeMap-sorted key/value pairs, recursing on each value.
  • Arrays → position-preserving, recursing on each element.
  • Fallback → .toString() with serializeJSON catch.

Test plan

  • New testMockArgsStructOrderIndependence — struct args built in different insertion order match.
  • New testMockArgsStructContainingCFC — structs containing CFC values match and don't trigger Adobe's JSON serializer cycle.
  • New testMockArgsDeepNesting — struct → array → struct canonicalises all the way down.
  • Full CI matrix green: Lucee 5/6/7, BoxLang 1/be/cfml@1, Adobe 2023/2025, format check.
  • Local full suite on Lucee 7.0.3.43 and 7.1.0.93-SNAPSHOT: 361/0/0/22.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This pull request addresses nondeterministic $args() matching in MockBox when arguments include nested structs (and other deep structures), where argument hashing could differ purely due to struct key iteration order. It introduces recursive value normalization to produce a stable, deterministic string representation for hashing, and adds tests to prevent regressions across nesting and CFC-containing structures.

Changes:

  • Replaced non-simple argument hashing fallback with a deterministic normalizeValue() that canonicalizes nested structs (sorted keys) and arrays (stable positional traversal).
  • Preserved existing CFC handling by serializing component metadata, now extended to components nested inside structs/arrays.
  • Added new MockBox specs to validate order-independence, CFC-in-struct handling, and deep nesting behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
system/MockBox.cfc Adds normalizeValue() and routes complex argument hashing through it to make $args() matching deterministic for nested structs/arrays and CFCs.
tests/specs/mockbox/MockBoxTest.cfc Adds regression tests covering struct insertion-order independence, CFC-in-struct cases, and deep nesting canonicalization.

Comment thread system/MockBox.cfc
Comment on lines +676 to +684
// Struct - sort keys
if ( isStruct( arguments.value ) && !isObject( arguments.value ) ) {
var sorted = createObject( "java", "java.util.TreeMap" ).init( arguments.value );
var parts = [];
for ( var key in sorted ) {
arrayAppend(
parts,
key & "=" & ( isNull( sorted[ key ] ) ? "null" : normalizeValue( sorted[ key ] ) )
);
Comment thread system/MockBox.cfc
Comment on lines +665 to +678
if (
isObject( arguments.value ) and
(
isInstanceOf( arguments.value, "Component" ) or structKeyExists(
getMetadata( arguments.value ),
"extends"
)
)
) {
return serializeJSON( getMetadata( arguments.value ) );
}
// Struct - sort keys
if ( isStruct( arguments.value ) && !isObject( arguments.value ) ) {
var sorted = createObject( "java", "java.util.TreeMap" ).init( arguments.value );
lmajano added a commit that referenced this pull request Aug 30, 2026
…upport (#204)

* TESTBOX-448: Fix MockBox $args() struct-order fragility + cross-engine value normalization

normalizeArguments() built its arg hash from struct.toString()/Java
Map.toString() for non-simple values, whose output depends on HashMap
iteration order - never guaranteed in CFML. Two structurally-equal
structs built in different insertion order could hash differently,
so $args() would silently miss a match. Latent for years; Lucee 7.1's
new ConcurrentHashMap-backed struct implementation (LDEV-5098) made
the fragility consistently visible.

Fix: normalizeValue() recursively canonicalizes composite argument
values instead of relying on raw Map/struct toString():
 - Structs: keys sorted via java.util.TreeMap (order-independent),
   values recursively normalized, then the whole thing is JSON-encoded
   rather than hand-joined with bare "," / "=" / "{}" - hand-joining
   let a string value containing those characters collide with a
   completely different struct that happened to serialize to the same
   raw text (e.g. {a:"1,b=2"} and {a:1,b:2} previously hashed
   identically). JSON escaping closes that off for both structs and
   arrays.
 - BoxLang Range: canonicalized via toString(), which fully captures
   bounds/step/exclusivity ("1..5" vs "1>..<5" vs "1..10.step(3)" all
   differ) - never iterated/materialized, since ranges can be huge or
   unbounded (an open-start range even throws if you try to iterate
   it). Checked before the array branch, since isArray() is true for
   a Range.
 - BoxLang Set: unordered by definition, so elements are normalized
   then sorted before JSON-encoding - the same order-independence
   struct keys get, applied to set elements. Type-tagged (as is Range)
   so a Set can never collide with an Array/string holding equivalent
   content.
 - CFC values: unchanged, still serialized via getMetadata() (moved
   into the same recursive helper so nested CFCs inside structs/arrays
   get the same treatment as top-level CFC args).

Range/Set checks are gated behind a computed IS_BOXLANG flag so
isRange()/isBoxSet() - which don't exist on Lucee/Adobe - are never
even attempted on those engines; short-circuit evaluation keeps the
whole branch inert there.

Tests: cross-engine coverage (struct order-independence, CFC-in-struct,
deep struct>array>struct nesting, and the delimiter-collision
regression) added to tests/specs/mockbox/MockBoxTest.cfc. Set/Range
coverage added as tests/specs/mockbox/MockBoxSetRangeTest.bx - a .bx
file, since BoxLang's `..` range operator isn't valid CFML syntax at
all on Lucee/Adobe (a parse-time failure, not just a missing-BIF one)
and TestBox's own bundle discovery already skips *.bx files entirely
on non-BoxLang engines, so this file is never compiled or run there.

Verified against BoxLang v1.17.0+58 end-to-end via the real
MockBox/$args()/createStub() API (not just the isolated normalization
logic): struct order-independence, delimiter-collision non-match, Set
order-independence across all three backing variants, Set-vs-Array
non-collision, Set-of-structs, Range match/non-match (bounds, step,
exclusivity), Range-vs-string non-collision, and unbounded-range
normalization all pass. Self-ran the full MockBoxTest + new
MockBoxSetRangeTest bundles (41 specs) - all new tests green; the
only 2 errors are a pre-existing, unrelated MockGenerator interface-stub
issue confirmed present on unmodified development too.

Supersedes #194 (external contributor PR for the same ticket): fixes
the delimiter-collision bug in that PR's normalizeValue() rewrite
(verified reproducible there against the real code, confirmed absent
on the current merged development baseline) and adds BoxLang Set/Range
support, which #194 did not handle.

* Fix cfformat alignment violation in testMockArgsNoDelimiterCollision

Inline the struct literal directly into save() instead of a separate
var declaration, avoiding a miscomputed manual alignment column
against the adjacent var statement.

---------

Co-authored-by: Claude <noreply@anthropic.com>
@lmajano

lmajano commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Merged manually. Superseded by #204 due to other approaches

@lmajano lmajano closed this Aug 30, 2026
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.

3 participants