@aemforms/eslint-plugin
v1.0.24
Published
ESLint rules for AEM Adaptive Forms design-canon + correctness checks — the JS-anchored subset of performance-bot's analyzers, sharing its matcher modules.
Readme
@aemforms/eslint-plugin
ESLint rules for AEM Adaptive Forms — they catch the mistakes in form JavaScript that cause runtime bugs, broken reuse, and data loss on resume, right in your editor and in CI.
They also make AI-assisted development safer: these rules encode the AEM Forms patterns that code generation most often gets wrong — async ordering, reusable-fragment scoping, and putting logic in code that belongs in the form model — so AI-written custom functions and rules are correct by construction, not just plausible-looking.
Install
npm i -D @aemforms/eslint-plugin eslint// eslint.config.js (flat config, ESLint 9 / 8.57+)
import aemForms from '@aemforms/eslint-plugin';
export default [
aemForms.configs.recommended,
// A few rules also cross-check your form's JSON on disk — point them at that folder:
{
files: ['**/*.js'],
rules: {
'aem-forms/storage-class': ['error', { formJsonRoot: 'local-form-json' }],
'aem-forms/fragment-path-validator': ['error', { formJsonRoot: 'local-form-json' }],
'aem-forms/fragment-qualified-name': ['error', { formJsonRoot: 'local-form-json' }],
'aem-forms/orphan-fragment-handler': ['error', { formJsonRoot: 'local-form-json' }],
},
},
];Suppress one finding: // eslint-disable-next-line aem-forms/<rule>. To silence just one finding
type of a multi-type rule (and keep the rest), use the ignoreTypes option:
'aem-forms/custom-fn-correctness': ['error', { ignoreTypes: ['jsonmodel-direct-read'] }] — the same
option name and semantics work in both ESLint and the performance-bot CLI.
A little context (so the rules make sense)
An Adaptive Form is authored as data (fields, panels, rules) plus small pieces of JavaScript:
- Custom functions / rule scripts — JS the form calls in response to a change, click, or event.
They run headless (also on the server, in tests), so they must not touch the browser (
window, the DOM) directly. - Rules — declarative expressions authored on a field (its value, whether it's visible/required, its validation). Logic that derives one value from others generally belongs in a rule, not in JS, because the form re-evaluates rules automatically (including when data is restored).
- Fragments — reusable sub-forms embedded into larger forms. A fragment must only touch its own
fields, addressed through
globals.fragment, never the parent form's fields (globals.form), or it breaks when embedded somewhere else. - Resume / prefill — the form can be rehydrated with saved data (a user resumes, or data is prefilled). This replays change handlers without a real user interaction, which is the source of a whole class of bugs below.
Rules
Correctness — bugs that surface at runtime
| Rule | What it catches |
|---|---|
| async-value-race | A value fetched from an API and saved inside .then(...), but read elsewhere before the fetch finishes (so the reader gets the old value) — or a .catch that saves an empty value on failure, quietly losing data. |
| request-error-handling | A request/api-client chain missing part or all of its error handling. An HTTP or network/connectivity failure RESOLVES { ok: false } — a .catch-only chain never sees it and the failure slips through as success (check res.ok too). A rejecting/malformed request payload genuinely rejects — a chain with neither a .catch nor an .ok check misses that case entirely, even through wrapper functions and await. See rule details. |
| interactive-change-guard | A UI action (moving focus, auto-advancing to the next field) that runs on every change, including when saved data is restored on resume. On resume this jumps focus/steps around and corrupts the screen. Gate it to real user edits. |
| custom-fn-correctness | An imperative setProperty that derives an editable property (a one-shot write that never recomputes — use field.bind), markFieldAsInvalid used imperatively (bind validationExpression instead), or reading _jsonModel directly (bypasses change tracking, so dependent rules won't re-run). See rule details. |
| headless-incompatible-usage | Custom-fn-surface JS assuming a live browser runtime it shouldn't — DOM access, window access, a raw HTTP call, navigation, or view/keystroke DOM in an orchestration file. See rule details. |
| custom-function | Direct $properties mutation, many single-field updates that should be one bulk update, or a custom event dispatched from a custom function. (Browser/window/DOM/HTTP access is headless-incompatible-usage, above.) |
| navigation-in-custom-fn | No findings of its own — both of its checks are headless-incompatible-usage now. Kept registered so existing config referencing this id doesn't error. |
| should-be-component | A field's own value being string-normalized (.toUpperCase(), .trim(), …) in orchestration code and written back as the submitted value — a keystroke concern that belongs in the field's component. See rule details. (View/keystroke DOM is headless-incompatible-usage, above.) |
Reuse — code that breaks when a fragment or component is embedded elsewhere
| Rule | What it catches |
|---|---|
| fragment-globals-scope | A fragment reaching into the parent form (globals.form.<field>) instead of its own scope (globals.fragment). Works in one form, breaks when reused in another. |
| fragment-qualified-name | Inside a fragment, addressing its own fields by the parent-form path (globals.form.<fragmentName>.…) instead of globals.fragment.…. |
| foreign-fragment-root | One fragment reading or writing another fragment's fields. Send an event instead and let the owning fragment update itself. |
| field-writes-sibling | One field directly setting another field's value or options. Broadcast an event and let the target field fill itself, so neither field depends on the other's internals. |
| dispatch-target | An event sent to a single field (only that field's children receive it) when it should go to the whole form — or sent as a plain object instead of a proper event. |
| fragment-path-validator | A field path in the code (globals.fragment/form.<path>) that doesn't exist in the form's JSON — a typo or a rename that wasn't updated, which silently reads undefined at runtime. |
| orphan-fragment-handler | An exported handler that no event or rule ever calls — dead code, usually a handler someone forgot to wire up. |
Model ownership — logic that belongs in the form, not in JavaScript
| Rule | What it catches |
|---|---|
| rule-vs-code | Deciding a field's value or visibility in JS (author it as a rule instead, so it re-evaluates automatically), or marking a field invalid imperatively (model it as the field's validation instead). |
| storage-class | A saved variable with no data./uistate. prefix (so it's unclear whether it should survive a resume), form data copied into a separate variable, reading form state inside a startup handler, or persisting a value that's COMPUTED from other form state (re-derive it instead of storing it). |
| content-in-code | User-facing text or error messages hardcoded in a function instead of authored on the form (where they can be translated and edited). |
| display-format-in-code | Formatting a display value in code (e.g. adding a currency symbol). Author it as the field's display format, so it survives when data is restored. |
| component-owns-model-concern | A custom component re-implementing something the form model already handles (formatting, validation, change events). |
| component-value-sync | A component that copies the field value once but never updates when the value later changes — so prefilled or restored data is lost. |
| component-model | A component defining a setting that duplicates a built-in one, or reading a setting the component never declares (so it's always empty). |
| block-decorator-input-mutation | Changing an input's value in the browser (event.target.value = … in an input/keydown/keyup handler) without committing it to the form model, so the model and the screen disagree. Set the value on the MODEL instead (setProperty/setValue); if it represents a user change, dispatch a UIChange event so the runtime records it as user input (eventSource:'ui'). |
| runtime-cls | Changing styles/classes while the form loads in a way that shifts the layout (a visible jump for the user). |
Auto-fixable: storage-class-namespace suggests data. / uistate. names.
Rule details
Every report message is self-contained — you shouldn't need this section to act on a finding. A few
rules (below) have exceptions or several distinct checks that are worth spelling out in more depth than
fits in a report message; that fuller version lives here. (Editors that support meta.docs.url, e.g.
VS Code's ESLint extension, also surface this page as a "documentation" link on hover — a bonus for
those editors, not the primary way findings are meant to be understood.)
rule-vs-code
Flags a function that only sets a computed editable property (no async/DOM/request/event-payload
read) — that's a derived value, visibility, constraint, or label, which belongs in a fd:rules
expression so it re-evaluates automatically, not in a change handler that has to be triggered and
re-triggered by hand. "Editable property" is the full set of runtime-writable properties (value,
visible, enabled, required, label, enum/enumNames, minimum/maximum, minLength/
maxLength, pattern, placeholder, description, …), derived from the runtime property matrix —
not just value/visibility. Also flags markFieldAsInvalid used imperatively — validity belongs in the
field's validationExpression (returns a boolean / {valid, errorMessage} / a Promise) so
validate()/validateAsync()/submit pick it up.
Exception: keep it imperative if this is a one-shot seed of user-editable data — e.g.
prefilling an address/name from a snapshot at init. That value is Class-1 dataRef data, restored via
importData on resume and editable by the user; a value rule would reclassify it as derived and
re-fire on every input change, overwriting the user's edit or the restored value on resume. If that's
your case, this is a false positive — suppress it with
// eslint-disable-next-line aem-forms/rule-vs-code.
This rule is the authoring-canon angle (prefer a content-authored fd:rules rule); a code-first
team can turn it off. The same code shape is ALSO flagged by custom-fn-correctness from the
correctness angle (use field.bind) — that finding stays on even when this rule is disabled.
custom-fn-correctness
AST-detectable custom-function correctness bugs — each a silent failure, all error:
non-reactive-set-use-bind— an imperativesetProperty(target, { <editableProp>: <computed> })is a ONE-SHOT write: it applies once and never recomputes when its inputs change. Usefield.bind('<prop>', (globals) => …)so the property is bound to a reactive, dependency-tracked expression that re-fires on change — the code-path equivalent of an authoredfd:rulesrule. This is the same code shaperule-vs-codeflags, but from the correctness angle:rule-vs-codesays "prefer authoring a rule" (disable-able); this says "if you keep it in code, make it reactive" (stays on). Same escape hatches asrule-vs-code— a set that reads an event payload / does async / touches the DOM is a legitimate imperative fill and is not flagged, and a literal-constant set ({ visible: true }) is imperative setup, not a derived value.markfieldasinvalid-in-fn-use-bind—markFieldAsInvalidimperatively fights the validity slot; bind the field'svalidationExpressioninstead (field.bind('validationExpression', fn)), a reactive expression thatvalidate()/submit pick up automatically.jsonmodel-direct-read— reading_jsonModel.*bypasses the dependency-tracking getter, so a rule that reads it will NOT re-fire when the value changes. Readfield.$value/ the tracked getter instead.
(Note: field.parent and the async-custom-function check were removed — the af2-web-runtime rule node
now resolves parent, and async functions register and run.)
content-in-code
User-facing copy — display text AND validation/error messages — belongs in authored content, not
a string literal in a rule/fragment function. It flags any prose string (2+ real words; a single
token or a formatting fragment like '₹ ' + num is not prose, so it won't trip this) reaching one of
three sinks:
- Returned directly from a function —
return 'Please enter a valid amount';. setProperty(field, { value: <prose> })— display copy pushed straight into a field's value.errorMessage: <prose>inside any object literal (not just areturn's direct argument — the object is routinely the implicit return of an inner.catch()arrow, e.g..catch(() => ({ valid: false, errorMessage: 'Bank not found.' })), so this is checked everywhere, not only where an explicitreturnsits).
The fix depends on which of the two you have:
- Display text → author a
dynamic-textfield with${key}placeholders (e.g.Hello ${firstName}, your loan amount is ₹${loanAmount}.) and set only the underlying data properties from code — never the finished sentence. - Validation/error message → author the field's
constraintMessages/validateExpMessage, and return{ valid: false }with noerrorMessage— the runtime falls back to the authored message automatically.
Prose reaching a sink through a same-file const/let identifier — the common "hoist the copy to a
module const" shape (const MSG = 'Please enter…'; return { valid:false, errorMessage: MSG },
including a props.x || DEFAULT_MSG fallback) — is flagged the same way as a direct literal; hoisting
the string to a constant doesn't move it out of code.
Not flagged, by design: reading an authored message off a property (e.g.
errorMessage: props.notServiceableMessage) — that's the correct pattern, not a violation; a short
non-prose code (errorMessage: 'ERR_001') — a code isn't authorable "content"; and the usual developer-
message exemptions (throw new Error(…), console.* args).
Note if you're looking for hardcoded-error-message: that finding type used to live under
hardcoded-config (which flagged an errorMessage: literal specifically, regardless of whether it was
prose). It moved here — hardcoded-config now owns only the unrelated hardcoded request-url shape —
so the whole "message hardcoded in code" concern has one home and one consistent rule (prose-gated,
same as every other check in this file).
storage-class
Six checks on setVariable/getVariable/$properties, all rooted in one rule: form-scoped state
must ride a data./uistate. namespace so it's unambiguous whether it survives a resume — and a
value that can be re-derived should not be persisted at all.
- Form data in a variable — a
setVariablename that matches adataRef-bound field name. Let the dataRef carry it (binding/importData); don't duplicate it into a variable. - Unnamespaced write — a form-scoped
setVariablewith nodata./uistate.prefix. Usedata.<name>for fieldless Class-1 data (or the$dataaccessor directly),uistate.<name>for persisted flow/UI state, oruistate._<name>for ephemeral scratch that must never persist. - Write-only variable (warning) — a
setVariablewhose name is never read anywhere in the analyzed JS. Likely dead, unless the reader is an authored rule or another module the static scan can't see. - Unnamespaced read — the read-side mirror of the unnamespaced-write check, for both
getVariable(...)and a direct$properties.<key>access. - Read inside an init handler — any form-scoped read (namespaced or not) inside a function named
*Init. On init/resume, a fragment shouldn't read form state directly at all — the orchestrator should build thecustom:*Initevent payload from state, and the fragment should consume it viaglobals.event.payload. - Derived value persisted — a form-scoped
setVariablewhose value is COMPUTED (arithmetic / ternary / template-concat) from a direct form-state read (getVariable(...),.value/.$value, or$properties.<key>). This is a Class-2 projection: re-derive it at the point of use (a shared calc or reactive rule), never store it — a persisted derivation drifts when its inputs change. Exempt: a value cached from an async/fetched result (write inside a.then/.catch/.finally, or anawait/requestin the value) — that is the irreducible cross-screen cache, not a projection.
Field-scoped access (globals.field, or a chain that descends into a specific named child rather than
terminating at the form/fragment-root anchor) is exempt from all five checks — that's a node's own,
non-restorable property bag, not journey state.
headless-incompatible-usage
Custom-fn-surface JS (fragment / custom-fn / *-rules.js) shouldn't assume a live browser runtime is
present — DOM access, window access, a raw fetch/XMLHttpRequest/axios/$.ajax call instead of
the form's request() tool, redirecting the browser directly (window.location, a hand-built <form>
submit), or view/keystroke DOM (addEventListener, querySelector/getElementById, createElement
except 'form', direct innerHTML/style/classList mutation) that belongs in a component's
decorate instead. One rule, one severity key, regardless of which primitive trips it or why it's
wrong — each finding still carries its own specific, type-appropriate message.
This rule runs the same underlying checks as custom-function, navigation-in-custom-fn, and
should-be-component (each still owns its OTHER, non-browser-runtime findings — see below) — so the
same code can trip more than one of the checks this rule wraps, and report more than once (e.g. a
document.querySelector inside a custom function is BOTH generic DOM access and, more specifically,
view DOM that belongs in a component — you'll see both).
should-be-component
Beyond the view/keystroke DOM findings folded into headless-incompatible-usage, this rule also warns
(not errors) on fragment-value-normalization: a field's own value being string-normalized
(.toUpperCase(), .trim(), .replace(), …) in orchestration code and written back as the submitted
value. Input sanitization like this is a keystroke concern owned by the field's component, or
expressible declaratively as the field's native pattern/format constraint — not orchestration code.
It's a warning because a deliberate one-off submit-time normalization is legitimate and can't be ruled
out statically. (This is distinct from display-format-in-code, which governs presentation, not the
submitted data.)
request-error-handling
A request/api-client call has two genuinely distinct failure shapes, and each needs its own handling:
- An HTTP-level failure (non-2xx response) OR a network/connectivity failure (offline, DNS, CORS,
connection refused) both RESOLVE
{ ok: false, status, body }— neither rejects. A chain that handles failure only in.catch(...)never sees either; branch onif (!res.ok)inside.theninstead. - A rejecting/malformed request payload — a Promise-valued body that itself rejects, or a synchronous
exception while encoding it — genuinely rejects. An
.okcheck alone never sees this; a real.catch()(not a trivial fail-open one) is still needed.
Two findings, both anchored on every literal request(...)/globals.functions.request(...) call and
resolved by climbing the real caller graph (through wrapper functions, await, and Promise.all) —
not just the immediate chain — before concluding a gap is genuine:
request-catch-only-no-ok-check— a chain has.catch(...)but nothing reads.ok/.statusanywhere in its.thencallbacks.request-missing-error-handling— a chain (or anawaited call) has neither a.catch(...)nor an.ok/.statusread, and none turns up anywhere reachable through its actual callers either.
Not flagged (a documented gap, not a bug): a chain with an .ok check but still no .catch() —
reasoning about "has some handling, still missing a piece" needs to know what already exists, which
risks a much higher false-positive rate against chains whose rejection is legitimately handled further
up an outer chain. Left to human review.
Before / after — the patterns that matter most
async-value-race — read the value where it's ready, not before:
// Wrong: the read runs before the fetch resolves — `stampDuty` is still the old value
const sd = getVariable('stampDuty');
request({ url: '/stampDuty' }).then((res) => setVariable('stampDuty', res.body.amount));
useIt(sd);
// Right: use the value inside the .then, once it's populated
request({ url: '/stampDuty' }).then((res) => {
setVariable('stampDuty', res.body.amount);
useIt(res.body.amount);
});request-error-handling — an HTTP/network failure resolves { ok: false } (a .catch never sees
it); a rejecting/malformed request payload genuinely rejects (an .ok check never sees THAT) — both
are needed:
// Wrong: no .ok check — a resolved {ok:false} flows on as success with res.body === undefined; no
// .catch either — a genuinely rejecting request produces an unhandled rejection
request({ url: '/x' }).then((res) => setProperty(field, { value: res.body.amount }));
// Right: branch on res.ok AND catch the reject case
request({ url: '/x' }).then((res) => {
if (!res.ok) { showError(); return; }
setProperty(field, { value: res.body.amount });
}).catch(showError);interactive-change-guard — only act on a real user edit, not a data restore:
// Wrong: on resume, restored data re-fires this and yanks focus around
if (field.$value.length === MAX) dispatchEvent(nextField, 'focus');
// Right: a real user change carries `eventSource`; a restore does not
const isUserEdit = !!(globals.event?.payload && 'eventSource' in globals.event.payload);
if (field.$value.length === MAX && isUserEdit) dispatchEvent(nextField, 'focus');fragment-globals-scope / fragment-qualified-name — a fragment addresses its own scope:
// Wrong: reaches into the parent form — breaks when this fragment is embedded elsewhere
const v = globals.form.employmentPanel.employer.$value;
// Right: portable — resolves wherever the fragment is embedded
const v = globals.fragment.employmentPanel.employer.$value;field-writes-sibling — broadcast, don't reach across fields:
// Wrong: the employer field directly fills the category field
setProperty(globals.form.employerCategory, { value: category });
// Right: broadcast; the category field fills itself from the event
dispatchEvent(globals.form, 'custom:employerSelected', { category }, true);rule-vs-code — derived values belong in a rule, not a change handler:
// Wrong: computing a derived value in JS — won't re-run when inputs change, and re-fires on restore
setProperty(globals.field, { value: principal + interest });
// Right: author it as the field's value rule (a form expression), which re-evaluates automatically.Rules that read your form JSON
storage-class, fragment-qualified-name, fragment-path-validator, and orphan-fragment-handler
cross-check against your form/fragment JSON — point them at the folder holding it via the
formJsonRoot option (see the config above). component-model finds its component's JSON
automatically. When that JSON isn't available, these rules skip only the JSON-dependent check and
still run their code-only checks.
Bot-only rules (not in this plugin)
A few analyzers anchor their finding to a JSON node, not a JS line, so they can't be ESLint rules and
run in the bot (lint CLI) only:
fragment-rule-form-ref— a fragment rule/event expression referencing the absolute form root$form.rules-in-content— rule/expression grammar (events/fd:events/rules/fd:rules,displayValueExpression/validationExpression) authored inline in a form/fragment content JSON; the content layer must carry no rules — move logic to the code/rule-store layer. The framework plumbingcustom:setProperty: ["$event.payload"]is skipped; structuralfd:*config is never flagged.
Compatibility
- ESLint
>= 8.57(flat config); ESLint 9 supported. - Node 20+.
- Self-contained — no other dependencies to install.
