# @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

```bash
npm i -D @aemforms/eslint-plugin eslint
```

```js
// 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](#request-error-handling). |
| `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](#custom-fn-correctness). |
| `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](#headless-incompatible-usage). |
| `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](#should-be-component). (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 imperative `setProperty(target, { <editableProp>: <computed> })`
  is a ONE-SHOT write: it applies once and never recomputes when its inputs change. Use
  `field.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 authored `fd:rules` rule. This is
  the same code shape `rule-vs-code` flags, but from the correctness angle: `rule-vs-code` says "prefer
  authoring a rule" (disable-able); this says "if you keep it in code, make it reactive" (stays on).
  Same escape hatches as `rule-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`** — `markFieldAsInvalid` imperatively fights the validity slot;
  bind the field's `validationExpression` instead (`field.bind('validationExpression', fn)`), a reactive
  expression that `validate()`/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. Read `field.$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 a `return`'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 explicit `return` sits).

**The fix depends on which of the two you have:**
- **Display text** → author a `dynamic-text` field 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 **no** `errorMessage` — 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 `setVariable` name that matches a `dataRef`-bound field name. Let the
  dataRef carry it (binding/`importData`); don't duplicate it into a variable.
- **Unnamespaced write** — a form-scoped `setVariable` with no `data.`/`uistate.` prefix. Use
  `data.<name>` for fieldless Class-1 data (or the `$data` accessor directly), `uistate.<name>` for
  persisted flow/UI state, or `uistate._<name>` for ephemeral scratch that must never persist.
- **Write-only variable** (warning) — a `setVariable` whose 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 the `custom:*Init` event payload from state, and the fragment should consume it via
  `globals.event.payload`.
- **Derived value persisted** — a form-scoped `setVariable` whose 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 an
  `await`/`request` in 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 on `if (!res.ok)` inside `.then`
  instead.
- A rejecting/malformed request payload — a Promise-valued body that itself rejects, or a synchronous
  exception while encoding it — genuinely rejects. An `.ok` check 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`/`.status`
  anywhere in its `.then` callbacks.
- **`request-missing-error-handling`** — a chain (or an `await`ed call) has neither a `.catch(...)` nor
  an `.ok`/`.status` read, 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:

```js
// 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:

```js
// 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:

```js
// 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:

```js
// 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:

```js
// 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:

```js
// 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 plumbing
  `custom:setProperty: ["$event.payload"]` is skipped; structural `fd:*` config is never flagged.

## Compatibility

- ESLint `>= 8.57` (flat config); ESLint 9 supported.
- Node 20+.
- Self-contained — no other dependencies to install.
