# eslint-config-ambassify

![Maintainer](https://vision.ambassify.eu/vision/template/http%3A%2F%2Fambassify.github.io%2Fbible%2Fgithub-tag-vision-template.html/x18.png?engine=bbbx&vars[name]=Gert&vars[size]=12&resize=fit)

Shared ESLint **flat config** for Ambassify (ESLint 9+, ESM), in three tiers:

- **environments** — globals only (`node`, `browser`, `nodeBrowser`).
- **rulesets** — rules only (`base`, `typescript`, `react`, `imports`, `lodash`,
  `strict`, `node`, `mocha`, …). The invariant: a ruleset declares no
  *environment* globals, so it pairs with any environment.
- **presets** — a complete per-environment bundle: `base` + the runtime's globals
  + its rulesets (`node`, `browser`, `nodeBrowser`). Frameworks (`react`, …) are
  rulesets you add on top.

Every export is a `files`-agnostic array of flat-config objects; you supply your
own `ignores` and file scoping.

## Install

```sh
npm install --save-dev eslint eslint-config-ambassify
```

`eslint` is a peer dependency. The plugins each tier needs ship as dependencies
of this package — you do not install them yourself.

> **CommonJS projects:** this package is ESM. In a project that is not
> `"type": "module"`, name your config **`eslint.config.mjs`** so it can
> `import` it. Everything else is unchanged.

## Usage

Batteries-included — pick one environment preset, add rulesets on top:

```js
// A Node service
import { presets, rulesets } from 'eslint-config-ambassify';

export default [
    { ignores: [ 'dist' ] },
    ...presets.node,          // base + node globals + n runtime checks
    ...rulesets.commonjs,     // drop this one if the service is ESM
    ...rulesets.imports,
];
```

```js
// A React Native (react-native-web) TypeScript library — rnw runs on the
// browser substrate, so the browser preset is the environment.
import { presets, rulesets } from 'eslint-config-ambassify';

export default [
    { ignores: [ 'lib', 'dist' ] },
    ...presets.browser,
    ...rulesets.react,
    ...rulesets.reactNative,  // StyleSheet checks, on top of `react`
    ...rulesets.typescript,
    ...rulesets.imports,
];
```

Hand-composed — own the environment yourself (skip presets):

```js
import { environments, rulesets } from 'eslint-config-ambassify';

export default [
    ...rulesets.base,
    ...environments.node,
    ...rulesets.node,
    ...rulesets.imports,
];
```

Each tier is also importable by subpath: `eslint-config-ambassify/presets`,
`/rulesets`, `/environments`.

### Test files

`mocha` is a ruleset overlay — scope it onto your own test files, on top of
whatever environment already applies there:

```js
import { defineConfig } from 'eslint/config';
import { presets, rulesets } from 'eslint-config-ambassify';

export default defineConfig(
    presets.node,
    rulesets.imports,
    { files: [ 'test/**' ], extends: [ rulesets.mocha ] },
    rulesets.allowDevDependencies({ ignores: [ 'src/**' ] }),
);
```

`extends` needs `defineConfig` — ESLint rejects the key outright in a bare config
array, so file-scoping a ruleset goes through it.

### Monorepos and path aliases

The TypeScript resolver behind `imports` loads **one** config, from the directory
eslint runs in — it does not search upward from the linted file. A monorepo whose
workspaces each carry their own tsconfig (and whose root carries none) therefore
resolves with no tsconfig at all, and every aliased import reports
`import-x/no-unresolved`. Say where the configs are:

```js
import { rulesets } from 'eslint-config-ambassify';

export default [
    ...rulesets.imports,
    rulesets.importResolver({ project: [
        'shared/tsconfig.json',
        'apps/native/tsconfig.json',
        'apps/webapp/jsconfig.json',
    ] }),
];
```

One block covers every workspace: the resolver picks a config per file and checks
the file against its `include`/`exclude`, so don't scope these with `files`.
Entries may be files, directories or globs (`[ 'shared', 'apps/*' ]` finds all
three), `jsconfig.json` counts, and relative paths resolve from where eslint runs
— use `` `${import.meta.dirname}/…` `` if that varies.

### Non-standard syntax (decorators)

No babel parser ships here — a heavy dependency for the few who need one, and for
legacy decorators nobody does. The typescript-eslint parser is already installed
(`rulesets/typescript.js` pulls it in) and parses them:

```js
import { defineConfig } from 'eslint/config';
import { presets, rulesets } from 'eslint-config-ambassify';

export default defineConfig(
    presets.browser,
    rulesets.react,
    { files: [ 'apps/webapp/src/**' ], extends: [ rulesets.decorators ] },
);
```

`decorators` is parser only — it carries no `@typescript-eslint` rules, so it is
safe on plain JavaScript. It sets `ecmaFeatures.jsx` (these files are `.js`, not
`.tsx`) and `experimentalDecorators`, both permissive. Take it from here rather
than importing `typescript-eslint` in your own config: that resolves only by
dependency hoisting and trips `no-extraneous-dependencies`.

### Browser-target compliance

`browserTargets` reports language features, ES built-ins and Web APIs that the
browsers you support don't have. It is how a library proves it runs on the shared
matrix, instead of adding babel and core-js as insurance against "using something
too new".

**Which browsers?** Yours, if you say — a `package.json` `browserslist` key, a
`.browserslistrc`, or `BROWSERSLIST` in the environment. If you don't,
`@ambassify/browserslist-config` is the default; it ships as a dependency here, so
this works with no setup at all.

**In a monorepo, put the key at the root.** Reading browserslist is each plugin's own
job and they don't do it the same way: es-x reads it once from the directory eslint runs
in, `compat` reads it per linted file, walking up from the file. Give the workspaces
their own keys and the two will check different browsers — measured on a fixture whose
root said Safari 17 and whose workspace said Safari 14, es-x reported against 17 while
`compat` reported against 14. One key at the root and they agree.

Then opt in, scoped to the code that actually runs in a browser — build scripts,
config files and tooling run on your `engines`, not on the matrix:

```js
import { defineConfig } from 'eslint/config';
import { presets, rulesets } from 'eslint-config-ambassify';

export default defineConfig(
    presets.browser,
    rulesets.react,
    rulesets.typescript,
    {
        files: [ 'src/**' ],
        ignores: [ '**/*.test.*', '**/*.stories.*' ],
        extends: [ rulesets.browserTargets() ],
    },
);
```

## Tiers

### environments

| Import | Globals |
| --- | --- |
| `node` | Node runtime globals. |
| `browser` | Browser globals, minus confusing window props (`name`, `find`, …) so `no-undef` still catches accidental use. |
| `nodeBrowser` | Only the globals shared by both runtimes (so `process`/`window` still error). The intersection, not the union. |

### rulesets

| Import | What it adds |
| --- | --- |
| `base` | House style (`@stylistic`) and core correctness rules. Every config starts here. |
| `typescript` | typescript-eslint parser + recommended rules and type-import conventions. Scoped to `.ts`/`.tsx`. |
| `react` | react + react-hooks recommended, JSX formatting, `jsx-no-leaked-render`, **automatic JSX runtime** by default. `prop-types` on for JS, off for TS. Works for both react-dom and react-native. |
| `reactNative` | `StyleSheet` checks for react-native(-web): `no-unused-styles` and `no-single-element-style-arrays`. The plugin's other rules (raw text, platform splits, inline styles/colors, style sorting) stay off — see the ruleset's comment. No globals: pair with the `browser` environment. Layer after `react`. |
| `decorators` | Parser only, no rules: parses legacy decorators (and JSX) in `.js` files via the typescript-eslint parser, so a JavaScript codebase needs no babel parser. See [Non-standard syntax](#non-standard-syntax-decorators). |
| `browserTargets({ targets, polyfills })` | **Opt-in, and a function** — syntax, ES built-ins and Web APIs your `browserslist` browsers don't support, via es-x (`@automattic/eslint-config-target-es`) and `eslint-plugin-compat`. Defaults to `@ambassify/browserslist-config` when the repo declares no browsers of its own, so it needs no setup. Scope it to browser code. See [Browser-target compliance](#browser-target-compliance). |
| `imports` | Import hygiene via `import-x`, across CommonJS and ESM: resolution, export correctness, `no-duplicates`, `no-extraneous-dependencies` (devDeps off by default — see `allowDevDependencies`), and ordering (builtin+external together, scoped packages after unscoped). Resolves via the TypeScript resolver then Node — the TS resolver reads one config from the directory eslint runs in, so monorepos need `importResolver`. |
| `node` | `eslint-plugin-n` runtime checks (deprecated APIs, unsupported features, process-exit). Rules only: no globals and no `sourceType` — pair with the `node` environment or preset, and with `commonjs` if the service is CommonJS. |
| `commonjs` | `sourceType: 'commonjs'` for `**/*.js`, so a CommonJS service is parsed the way Node runs it. ESLint's default treats `.js` as ESM whatever `package.json` `type` says, which makes sloppy-mode syntax a parse error. Scoped to `.js` on purpose: `.cjs` is already a script and `.mjs` must stay ESM. |
| `mocha` | Mocha recommended rules and test globals, tuned. A test overlay — scope it to your own test files. |
| `scripts` | Overlay for CLI/tooling entry points: turns off `no-console` and `n/no-process-exit`. Scope it to your script files; pairs with the `node` ruleset/preset. |
| `lodash` | `eslint-plugin-lodash` recommended, minus the opinionated method/chain/typecheck preferences. |
| `strict` | Requires a global `'use strict'` directive in `.js`/`.cjs`, and includes `commonjs`. For CommonJS codebases; ES modules are already strict and forbid the directive. |
| `importResolver({ project, noWarnOnMultipleProjects })` | Helper: point the TypeScript resolver at your tsconfig(s) — required in a monorepo, where the default (one config, from eslint's working directory) finds nothing and every aliased import reports `no-unresolved`. `project` takes files, directories, or globs; `jsconfig.json` counts. See [Monorepos](#monorepos-and-path-aliases). |
| `allowDevDependencies(files \| { files, ignores })` | Helper: re-allow devDependency imports in tests/tooling, scoped with eslint's `files`/`ignores`. `allowDevDependencies(['test/**'])` (allow-list) or `allowDevDependencies({ ignores: ['src/**'] })` (everywhere but source). |
| `importOrder({ alias, constants, messages })` | Helper: opt-in `import-x/order` override adding alias-specific path groups — aliased modules as `internal`, constants (and optionally messages) floated to the end. Layer after `imports`, e.g. `importOrder({ alias: ['@', '@@'] })`. |

### presets

| Import | Bundles |
| --- | --- |
| `node` | `base` + node environment + `node` ruleset. |
| `browser` | `base` + browser environment + browser rules. Also the environment for react-native-web — add `react` and `reactNative`. |
| `nodeBrowser` | `base` + shared globals + both environments' rules, for isomorphic libraries. |

## Opt-outs

**Classic JSX runtime.** `react` defaults to the automatic runtime. A project
still importing `React` into every file re-enables the classic checks:

```js
import { rulesets } from 'eslint-config-ambassify';

export default [
    ...rulesets.base,
    ...rulesets.react,
    { rules: {
        'react/react-in-jsx-scope': 'error',
        'react/jsx-uses-react': 'error',
    } },
];
```
