@aemforms/eslint-plugin
v1.0.12
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>.
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 whose failure is handled only in .catch. AEM requests don't throw on failure — they succeed with { ok: false } — so the .catch never runs and errors slip through as success. Check res.ok instead. |
| 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 async custom function (the form silently ignores it, so it never runs), field.parent (undefined here — use field.$parent), or reading _jsonModel directly (bypasses change tracking, so dependent rules won't re-run). |
| custom-function | Browser access (window, the DOM) or a raw HTTP call inside a custom function. Custom functions run headless, so this breaks outside the browser. Also flags many single-field updates that should be one bulk update. |
| navigation-in-custom-fn | Redirecting the browser from a custom function (window.location, or a hand-built <form> submit). Use the form's own submit/redirect. |
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, or reading form state inside a startup handler. |
| 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 without writing it back to the form model, so the model and the screen disagree. |
| runtime-cls | Changing styles/classes while the form loads in a way that shifts the layout (a visible jump for the user). |
Auto-fixable: field-parent rewrites .parent → .$parent; storage-class-namespace suggests
data. / uistate. names.
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 — AEM requests resolve { ok: false }, they don't throw:
// Wrong: .catch never fires; a failed request flows on as success with res.body === undefined
request({ url: '/x' }).then((res) => setProperty(field, { value: res.body.amount })).catch(showError);
// Right: branch on res.ok
request({ url: '/x' }).then((res) => {
if (!res.ok) { showError(); return; }
setProperty(field, { value: res.body.amount });
});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.
Compatibility
- ESLint
>= 8.57(flat config); ESLint 9 supported. - Node 20+.
- Self-contained — no other dependencies to install.
