Skip to content

feat(model): report unknown arguments to framework declarations - #4393

Merged
bpamiri merged 8 commits into
developfrom
peter/strict-arguments
Oct 6, 2026
Merged

bpamiri merged 8 commits into
developfrom
peter/strict-arguments

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

New setting: strictArguments

A Wheels function ignores a named argument it doesn't know, so a typo or another framework's name fails silently: hasMany(foreign_key = "postId"), findAll(orderby = "name"), t.string(nullable = false). The call runs without the option, and the mistake shows up later as wrong data.

strictArguments reports those arguments:

Value Behaviour Default in
"warn" One wheels.log warning per model or controller, function and argument, for the life of the application, with a suggested name development
"throw" Raises Wheels.UnknownArgument with the same text opt-in
"off" Ignored, as before testing, production, maintenance
Wheels: `hasMany()` on `Post` got an argument it doesn't know: `foreign_key`. Unknown arguments are ignored. Did you mean `foreignKey`?

What is checked

Only functions with a fixed set of arguments:

  • associations: belongsTo(), hasMany(), hasOne(), nestedProperties();
  • validations: validate*();
  • callback registration: beforeSave(), afterCommit() and the others;
  • property() and table();
  • the finders: findAll(), findOne(), findByKey(), findFirst(), findLastOne() and the dynamic finders;
  • controller configuration: caches(), protectsFromForgery(), provides(), usesLayout();
  • migration column functions: t.string() and the others.

Not checked are functions that turn unknown arguments into something: property values, route parameters, HTML attributes, view variables. filters() is also left out, because its extra named arguments become the filter's arguments.

The declared parameters are read from the framework's own definitions (a bare wheels.Model, or the controller component), so an app override doesn't change them. They're cached for the application's lifetime. When a definition's metadata can't be read, nothing is checked. Documented aliases are accepted: property, method, associations, format. So are findAll()'s returnType / keyColumn, and a property passed by name to a dynamic finder.

The finders pass arguments on to each other, so an unknown argument is reported once, under the finder the app called (findOneByEmail(), not findAll()). A model method that overrides a finder and passes its own arguments on with argumentCollection = arguments is reported too. The message says to remove them first, and the new guide page and the overriding guide show how.

With the setting "off", a checked call only reads the setting (one small function call) and returns; nothing is cached or looked up.

Framework calls that forwarded their own arguments

Running the core suite with the check on found five places where the framework itself passed arguments a function doesn't take. Each is fixed here, and none changes behaviour:

  1. count() passed ifNull (appended from its function defaults) through $calculate() to findAll(). $calculate() now keeps it locally.
  2. Migration.addColumn() / changeColumn() passed their own table, referenceName and addColumns into t.<type>(). They now pass only the column options.
  3. findOrCreateBy*() passed the property values meant for create() to findOne(). With the check on, it passes only finder options.
  4. A dynamic finder's property passed by name (findOneByEmail(email = ...)) was forwarded after being read into the where clause. With the check on, it's dropped first, unless the property is also a finder option.
  5. Lucee iterates a declared but unpassed parameter as a null. The check skips those keys.

Follow-ups after review

Two specs that passed ignored arguments

  • callbacksSpec.cfc called findAll(orderby = "views DESC"); orderby was ignored. It now uses order = "views DESC, id".
  • crudSpec.cfc called findAll(..., result = "result"); nothing reads result. Removed.

Measured false positives

  • Core suite, Lucee 7 + SQLite, with "warn" forced on in every environment: 0 warnings after the fixes above, 6820 specs passing.
  • The example app (wheels-dev/cfwheels-example-app): it's a 2.x layout that can't boot on 4.2, so I scanned it statically with the same accepted lists. 69 checked calls and 18 unknown arguments, all null= on migration columns. They were true positives when measured: 2.x's column option was null, and 3.0 renamed it to allowNull with no alias, so a 2.x null = false created a nullable column. fix(migrator): read the pre-3.0 null column option as an alias of allowNull #4395 (merged) restores null as a deprecated alias and adds a wheels upgrade check advisory. $addTypedColumns() maps null to allowNull before the strict check runs, so with this PR null gets the deprecation warning, not an unknown-argument report. A spec pins that t.string(null = false) under "throw" neither throws nor warns and still sets allowNull.
  • wheels new and generator templates: 0.

Specs

vendor/wheels/tests/specs/global/strictArgumentsSpec.cfc:

  • "throw":
    • an unknown association argument, with the suggestion;
    • unknown validation and callback-registration arguments;
    • the documented aliases pass;
    • finders are named by the caller, including dynamic finders;
    • finder options and internal forwarding pass: count(), exists(), findFirst(property), findLastOne(properties), a dynamic finder with a named property;
    • an unknown migration column option, and the valid ones;
    • unchecked functions;
    • positional and $ arguments are ignored.
  • "warn": logged once per class, function and argument, with no throw; a finder argument is logged once.
  • "off": nothing.
  • The default per environment.

The specs that use "throw" fail before anything is registered, so no shared model class changes.

Docs

  • New guide page: digging-deeper/strict-arguments.
  • A line in environments-and-configuration and in overriding-core-methods.
  • .ai/models.md and .ai/migrations.md: consumer copy and wheels new template.
  • An added fragment.

Verification

  • Lucee 7 + SQLite, after the latest develop merge (with fix(migrator): read the pre-3.0 null column option as an alias of allowNull #4395): full suite 6974/6974. Earlier, with "warn" forced on: 6949/6949. The only warnings logged come from the strict spec's own "warn" cases.
  • RustCFML, after the latest merge: 6960 pass, 0 fail.
  • Complexity gate, consumer-docs check, guides build (853 pages) and verify-docs on the new and edited pages (6/6) pass.
  • Docker, full core suite on SQLite at the previous develop merge (ea433d9): BoxLang 6868, Lucee 7 6868, Lucee 6 6868, Adobe 2023 6863, Adobe 2025 6863, all 0 fail, 12/12 strict specs on each.

🤖 Generated with Claude Code

bpamiri and others added 3 commits October 5, 2026 09:32
New strictArguments setting. "warn" (the development default) logs an
argument that a declaration doesn't know, once per model or controller,
function and argument, with a suggested name; "throw" raises
Wheels.UnknownArgument; "off" (the default elsewhere) ignores it as before.

Checked: associations, validations, callback registration, property(),
table(), the finders (dynamic finders included), caches(),
protectsFromForgery(), provides(), usesLayout() and migration column
functions. Functions that turn unknown arguments into something (property
values, route params, HTML attributes, view variables, filter arguments)
are not.

Framework calls that passed their own arguments on are trimmed so they
don't report: count() no longer passes ifNull to findAll(),
addColumn()/changeColumn() pass only column options, and findOrCreateBy()
passes only finder options to findOne(). Two core specs passed arguments
findAll() ignores (orderby, result); they now use order or drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ c718388: code APPROVE (final sign-off after matrix run 37364909140 and PR CI)

  • Defaults. events/init/debugging.cfm sets strictArguments = environment == "development" ? "warn" : "off", next to showDebugInformation / showErrorInformation. $strictArgumentsMode() treats a missing, non-simple or unrecognised value as "off", so "throw" is reachable only when the app sets it explicitly. The spec "is off outside development" pins that.
  • A closed list of checked functions. $strictArgumentsSource() is an explicit allow-list: associations, validations, callback registration, property / table, the five finders, and the four controller-configuration functions. Anything not on it returns "" and is never checked, which keeps it away from the functions that turn unknown arguments into something (property values, route params, HTML attributes, view variables, filters()).
  • Declared names come from the framework's own definitions. They're read from a bare wheels.Model (the existing modelSuperPrototype) or the controller component, so an app override can't widen or narrow them. They're cached for the application's lifetime, and an unreadable definition means nothing is checked rather than a guess.
  • Finders.
    • findAll additionally accepts returnType / keyColumn.
    • findOne / findByKey / findFirst accept findAll's names, since they pass arguments on, and findLastOne adds properties.
    • A dynamic finder strips the property it consumed into the where clause before checking, unless that name is also a finder option, and reports under the method the app called (findOneByEmail).
    • findOrCreateBy* gives findOne only finder options and $ keys. The property values still go to create().
    • The finders report once per owner, finder family and argument, so forwarding between finders doesn't double-report. The message tells an overriding method that forwards argumentCollection = arguments to StructDelete() its own arguments first.
  • The five internal forwarding fixes don't change behaviour. $calculate() now keeps ifNull locally instead of passing it to findAll(), which never read it. addColumn / changeColumn pass only column options. Both dynamic-finder paths only drop arguments the callee ignored. The "declared but unpassed parameter iterates as null on Lucee" skip is correct.
  • The off-path cost. It's one $strictArgumentsMode() call (a few key checks, an LCase and a ListFind) per $args() call, plus the same guard at the three direct call sites. Nothing is cached or looked up while it's off.

Notes, not blocking:

  1. $args() is a hot path. It runs for most helpers, view helpers included, so "zero cost when off" is really "one small function call per helper call". If that ever shows up in a profile, caching the resolved mode (or a strictArgumentsOn boolean) next to the setting at application start would make the off check a single key read.
  2. Argument names can come from request input. An app that calls a checked finder with argumentCollection = params passes request keys through as argument names. In "warn" (development only) each distinct unknown key adds an entry to application.wheels.$strictArgumentsSeen and a log line, without bound. In opt-in "throw", any extra parameter becomes an error. That's arguably the point of "throw", but a sentence in the new guide page ("don't pass request params straight through to finders with this on") would head off the surprise.

Argument names can come from a request (argumentCollection = params), so
"warn" stops recording after 500 distinct unknown arguments and logs one
line saying so. The guide says to pick request keys rather than pass params
straight into a framework function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Wheels Test Results

35 880 tests   35 155 ✅  11m 49s ⏱️
 2 840 suites     725 💤
     5 files         0 ❌

Results for commit 4f21e7f.

♻️ This comment has been updated with latest results.

bpamiri and others added 2 commits October 5, 2026 16:27
…ents

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

# Conflicts:
#	vendor/wheels/migrator/TableDefinition.cfc
With the null alias merged, t.string(null = false) under
strictArguments = "throw" neither throws nor warns and still sets allowNull;
the unknown-option spec and the docs use `nullable` instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ 1e7a665: delta APPROVE (final pending narrowed 37389454351 + PR CI)

I compared the PR's diff against develop file by file with the approved c718388. The only changes are below; everything else the merge brought in is #4395 and other already-merged work.

  • Merge with fix(migrator): read the pre-3.0 null column option as an alias of allowNull #4395: $addTypedColumns calls $applyNullAlias(arguments.args) on line 172, before $checkArguments on line 174, so null= becomes allowNull= before the strict check sees it. The comment above it was updated to say the same.
  • Seen-cache cap (972c99e): in util.cfm, once $strictArgumentsSeen holds $strictArgumentsSeenCap() (500) entries, warn mode writes one final "stops logging" line, guarded by $strictArgumentsCapped, and then records nothing more. That bounds memory when argument names come from a request through argumentCollection = params. The check sits after the already-seen early return, so known keys cost nothing. The struct isn't locked, so two concurrent requests could push it a few entries past 500, which doesn't matter for a cap. The spec pre-fills the cache, checks the count stays at the cap and the flag is set, and restores state through the existing beforeEach/afterEach save and restore.
  • Null-alias spec (1e7a665): in throw mode t.string(null=false) neither throws nor (in warn mode) records anything, and allowNull ends up false. null goes in through argumentCollection, which is safe on engines that treat it as a keyword. The old "unknown column option" case now uses nullable, a name that really is unknown.

The final reads the narrowed run 37389454351 (all engines plus RustCFML × SQLite, which covers both nullAliasSpec and strictArgumentsSpec) together with the earlier full run 37379662296 on c718388, plus PR CI. The delta is engine-neutral CFML and specs with no DB-specific SQL, so SQLite legs cover it.

@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 @ 1e7a665: matrix side green, final held on the docs-harness verify job, which this PR doesn't cause

  • Narrowed run 37389454351 (on this head) completed with success. The 5 SQLite legs (adobe2023, adobe2025, boxlang, lucee6, lucee7) ran 7133 tests each with 0 failures and 0 errors. strictArgumentsSpec passed 14 of 14 on every leg, including the two new tests, and nullAliasSpec passed 5 of 5 on every leg. The rustcfml leg is green too.
  • Full matrix 37379662296 (on c718388): 32 of 32 legs with 0 failures and 0 errors.
  • Why the narrowing is enough: the delta is engine-neutral CFML plus specs with no DB-specific SQL, so SQLite legs on every engine cover it.
  • PR CI: 32 pass / 1 fail (verify). That job's harness step failed tutorial driver walks mini-tutorial end to end (the known wheels reload failure, fixed by fix(docs): start the fixture server before tutorial CLI steps that need it #4444) and was then cancelled after the step hung ("The operation was canceled", the hang fix(docs): bound every harness command so a stuck wheels can't hang the run #4447 bounds). Neither touches this PR, which changes no docs-harness code.

This becomes the final once verify is green: after #4444 and #4447 land and the branch picks them up, or after a re-run.

@bpamiri

bpamiri commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ 4f21e7f: APPROVE (final)

This head is a develop merge. The PR's own changed lines against develop are identical to 1e7a665, which I reviewed; that review covers the #4395 ordering, the 500-entry seen cap and the null-alias spec.

Ready to merge.

@bpamiri
bpamiri merged commit f64bc0c into develop Oct 6, 2026
48 of 49 checks passed
@bpamiri
bpamiri deleted the peter/strict-arguments branch October 6, 2026 01:14
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.

1 participant