exadev-eslint-config 2.35.3 → 2.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -14
- package/dist/index.cjs +1817 -1001
- package/dist/index.d.cts +21 -2
- package/dist/index.d.ts +21 -2
- package/dist/index.js +1817 -1002
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -205,6 +205,7 @@ export default tseslint.config(
|
|
|
205
205
|
| [Tooling wiring](#tooling-wiring) | Off unless given (only a repository can say it wants its publish checks, root tooling and hooks enforced); the package.json sections need the optional peer `@eslint/json` | `exadevConfig({ toolingWiring })` / `toolingWiringConfig(options)` |
|
|
206
206
|
| [Turbo environment variable checking](#environment-variables-read-in-source) | Auto-detected: on if `eslint-plugin-turbo` is installed | `exadevConfig({ turboEnv })` |
|
|
207
207
|
| [Required Markdown headings](#required-markdown-headings) | Off unless given (only a repository can say which documents need which headings); needs the optional peer `@eslint/markdown` | `exadevConfig({ markdownHeadings })` / `markdownHeadingsConfig(options)` |
|
|
208
|
+
| [Agent skills and plugin marketplaces](#agent-skills-and-plugin-marketplaces) | Auto-detected: on if the project has a `.claude-plugin/marketplace.json`, a `skills/<name>/SKILL.md` or a `plugins/<plugin>/skills/<name>/SKILL.md` (symbolic links to those directories count); the SKILL.md rules need the optional peer `@eslint/markdown` and the manifest rules `@eslint/json` | `exadevConfig({ agentSkills })` / `agentSkillsConfig(options)` |
|
|
208
209
|
|
|
209
210
|
Every tri-state option above (`true`/`false`/`undefined`) is passed through the named `exadevConfig(options, ...userConfigs)` factory export:
|
|
210
211
|
|
|
@@ -297,7 +298,7 @@ Both are approximations. They report a file without the directive that is only e
|
|
|
297
298
|
| --- | --- | --- |
|
|
298
299
|
| `no-non-serialisable-server-prop` | `names` | Prop names whose value must be data. Replaces the default `['component']`. |
|
|
299
300
|
| `no-non-serialisable-server-prop` | `allowElements` | JSX element names as written (`Link`, `Lib.Icon`) whose props are not checked. |
|
|
300
|
-
| `no-external-member-jsx-tag` | `allowSources` | Import specifiers, in the [specifier pattern](#specifier-patterns)
|
|
301
|
+
| `no-external-member-jsx-tag` | `allowSources` | Import specifiers, in the [specifier pattern](#specifier-patterns) form (each also selects everything beneath it), whose member tags are not reported. |
|
|
301
302
|
| `no-external-member-jsx-tag` | `allowTags` | Tags as written (`Ctx.Provider`) that are not reported. |
|
|
302
303
|
|
|
303
304
|
The preset enables both at `error` with the defaults. To tune them, add a later block that sets the rule again for the same files:
|
|
@@ -427,6 +428,10 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
427
428
|
| `non-vacuous-guard` | | **A guard test must show it can fail:** an unconditional lower bound on what it discovered, and its pattern checked against an input it must catch and one it must not. Wired by `testHygieneConfig`. See [Guard and conformance test hygiene](#guard-and-conformance-test-hygiene). |
|
|
428
429
|
| `no-multiline-template-literal` | ✓ | **An untagged template literal whose value spans several lines should be an array of lines joined with `\n`.** Autofixes only when the rewrite provably yields the same string. See [Multi-line template literals](#multi-line-template-literals). |
|
|
429
430
|
| `markdown-required-heading` | | **A Markdown document must contain each configured heading:** a `{ depth, text }` per required heading, matched on the text as it renders. A Markdown-language rule (`@eslint/markdown`). Wired by `markdownHeadingsConfig`. See [Required Markdown headings](#required-markdown-headings). |
|
|
431
|
+
| `skill-frontmatter` | | **A SKILL.md must start with valid Agent Skills frontmatter:** a YAML mapping whose `name` equals the skill's directory, is lower-case letters and digits in hyphen-separated runs and is within the specification's length limit, a non-empty `description` within its limit, and `metadata.internal` a boolean when present. Other keys are accepted. A Markdown-language rule (`@eslint/markdown`). Wired by `agentSkillsConfig`. See [Agent skills and plugin marketplaces](#agent-skills-and-plugin-marketplaces). |
|
|
432
|
+
| `skill-name-unique` | | **A skill name must be defined once across the repository.** The skills CLI silently drops a skill whose name is already taken. Scans the working directory for other SKILL.md files once per process. A Markdown-language rule. Wired by `agentSkillsConfig`. |
|
|
433
|
+
| `marketplace-manifest` | | **A Claude Code `marketplace.json` must be well formed and agree with the plugins it lists:** no entry `version` or `skills` key, a local `source` starting with `./` whose directory holds a `plugin.json` of the same name, no directory listed twice, every plugin under `plugins/` listed. A JSON-language rule (`@eslint/json`). Wired by `agentSkillsConfig`. |
|
|
434
|
+
| `plugin-manifest` | | **A Claude Code `plugin.json` must name its plugin directory, declare no `skills` key and equal the `package.json` version beside it when that declares one.** A JSON-language rule. Wired by `agentSkillsConfig`. |
|
|
430
435
|
| `no-defensive-fallback` | | **An empty-literal fallback hides a value that should have been modelled as absent.** Reports `value ?? []`, `value \|\| ''` and the logical-assignment forms for an empty array, object or string, `0`, `false` or `null`, and a `catch` clause or `.catch()` handler that discards the error and returns nothing or a fixed value. Opt-in, not part of `recommended` or the default export. Needs no type information. See [Defensive fallbacks](#defensive-fallbacks). |
|
|
431
436
|
| `timeout-aborts-request` | | **A `Promise.race` timeout must abort the request it raced against,** not only settle the race: the timer callback calls `.abort()` on an `AbortController` created in the same function, the timer id is cleared in a `finally`, and a `catch` returns early on `controller.signal.aborted`. Opt-in, needs no type information. See [Timeout races](#timeout-races). |
|
|
432
437
|
| `no-non-serialisable-server-prop` | | **A configured prop (default `component`) of a JSX element in a file without `"use client"` must be serialisable data.** A function or component reference cannot cross from a server component to a client component. Enabled by the Next.js preset. See [Server component boundary](#server-component-boundary). |
|
|
@@ -458,7 +463,9 @@ In every mode, re-exports are banned outside a permitted barrel, and a permitted
|
|
|
458
463
|
|
|
459
464
|
`required-exports`, `required-imports`, `import-policy` and `filename-pattern` check one file against a convention that depends on its path. Each self-scopes through `context.filename`, so it is a no-op on every other file and can sit in a shared config without a `files` array of its own. None needs type information, and none is in `recommended`, since each takes options only a project can supply.
|
|
460
465
|
|
|
461
|
-
`files` in these rules is a glob, or a list of globs,
|
|
466
|
+
`files` in these rules is a glob, or a list of globs, matched against the path relative to ESLint's working directory. A file glob is a [minimatch](https://github.com/isaacs/minimatch) glob, the matcher ESLint applies to a config's `files`, with every form minimatch supports (braces, extglob, POSIX classes, `[...]` classes, `**`), so a glob selects here what it selects there. Two things are this package's own: a leading `!` marks an exclusion (so a glob that opens an extglob negation is written `@(!(a))x`, not `!(a)x`), and a glob without a `/` names a file at any depth, so `fake.ts` and `**/fake.ts` are the same. A wildcard in a rule scope does not match a dot-prefixed name unless the glob writes the dot (minimatch `dot: false`), whereas the agent skills options, which must select what ESLint selects, match dot-prefixed names (`dot: true`, as ESLint does). A glob minimatch cannot compile, one that starts with `#` (a comment to minimatch) or with a second `!`, and one that starts or ends with `/` (a file path is neither absolute nor a directory, so it would select nothing: write `src/**`, not `/src/**` or `src/`) is rejected when the option is read, with an error naming the option and the glob. A specifier pattern may start with `/`.
|
|
467
|
+
|
|
468
|
+
Because a file glob is a minimatch glob, these follow minimatch, which other glob libraries read differently: a brace group with one element (`{b}`) is literal text; `{a..c}` and `{1..3}` are ranges; extglob groups (`@(a|b)`, `!(a)`), POSIX classes (`[[:alpha:]]`) and `[\^a]` negation are live; an empty alternative (`{,a}`) is an empty path piece, not a dropped one; `dir/**` selects what is under `dir` but not the bare path `dir`, for a file glob as for a specifier (where the pattern's own subpath rule then also selects `dir`); a leading `!` is always this package's exclusion marker, so `!!!x` excludes `x`; and an escaped pipe before a wildcard (`\|*`) is read by minimatch as an alternation, so it matches nearly any one-segment path. The forms rejected when an option is read are exactly four: a glob minimatch cannot compile, a glob that starts with `#`, a glob with a second `!` after the exclusion marker, and a file glob that starts or ends with `/`.
|
|
462
469
|
|
|
463
470
|
### Required exports
|
|
464
471
|
|
|
@@ -493,9 +500,10 @@ Each unsatisfied entry reports once on the program node.
|
|
|
493
500
|
|
|
494
501
|
`required-imports` (`from`) and [`import-policy`](#import-policy) (`specifiers`) match module specifiers the same way:
|
|
495
502
|
|
|
496
|
-
- Patterns
|
|
503
|
+
- Patterns are minimatch globs read by this package alone (a leading `#` is a Node subpath import here, not a comment, and a leading `!` marks an exclusion), and each also selects everything beneath it, so `fs` selects `fs/promises` and `@scope/pkg` selects `@scope/pkg/sub`, and a pattern ending in `/**` also selects the directory it names.
|
|
497
504
|
- A leading `node:` is ignored on both sides, so `fs` and `node:fs` are one builtin.
|
|
498
505
|
- A relative specifier is resolved against the linted file's directory to a path relative to the working directory, then matched only by patterns containing a `/`. A bare pattern such as `fs` therefore never selects `./fs`. A relative specifier that leaves the working directory matches nothing.
|
|
506
|
+
- A pattern minimatch cannot compile is rejected when the option is read, naming the option and the pattern, since one that never matches would leave a hole in a policy.
|
|
499
507
|
- Nothing is resolved through `node_modules` or the TypeScript path map, so an alias such as `@/db` is matched as the string it is.
|
|
500
508
|
|
|
501
509
|
## Import policy
|
|
@@ -556,7 +564,7 @@ export default defineConfig(
|
|
|
556
564
|
);
|
|
557
565
|
```
|
|
558
566
|
|
|
559
|
-
The `no-restricted-imports` options map across as follows. A `paths` entry's `name` is a `specifiers` entry, except that a specifier also selects everything beneath it, so `pkg` covers `pkg/sub` as well. A `patterns` entry's `group` is a list of globs in the [specifier pattern](#specifier-patterns)
|
|
567
|
+
The `no-restricted-imports` options map across as follows. A `paths` entry's `name` is a `specifiers` entry, except that a specifier also selects everything beneath it, so `pkg` covers `pkg/sub` as well. A `patterns` entry's `group` is a list of globs in the [specifier pattern](#specifier-patterns) form, with a leading `!` excluding. There is no `regex`: minimatch's `**`, `*`, `?`, `[...]`, braces and `!` excludes express the sets a ban needs, and a character class (`[Ff]ake`) stands in for case-insensitive matching. `importNames`, `allowImportNames` and `message` carry over, `allowTypeImports` is the same flag, and a namespace import, dynamic import, `require()` or `export *` of a module whose `importNames` are banned, or whose names are limited by `allowImportNames`, is reported, since each takes every name. Avoid the `no-restricted-syntax` workaround (`ImportExpression > Literal[value=/.../]`): flat config replaces a rule's options when a later block sets the same rule for the same files, so that selector silently switches off any other `no-restricted-syntax` list the files already have.
|
|
560
568
|
|
|
561
569
|
### Import-policy recipes
|
|
562
570
|
|
|
@@ -733,7 +741,7 @@ export default defineConfig(
|
|
|
733
741
|
);
|
|
734
742
|
```
|
|
735
743
|
|
|
736
|
-
The same object goes to `exadevConfig({ pureModules: { files: ['src/core/**'] } })`. `files` follows the [file glob
|
|
744
|
+
The same object goes to `exadevConfig({ pureModules: { files: ['src/core/**'] } })`. `files` follows the [file glob form](#file-level-rules) used across this package, with a leading `!` excluding. Like any flat-config `files` list, a glob that ends in `/**` or `/*` selects files only alongside a block that names their extension, which `exadevConfig()` provides for JavaScript and TypeScript.
|
|
737
745
|
|
|
738
746
|
In the selected files the rule reports:
|
|
739
747
|
|
|
@@ -742,7 +750,7 @@ In the selected files the rule reports:
|
|
|
742
750
|
- `Date.now`, `Math.random`, `performance.now`, `crypto.randomUUID`, `crypto.getRandomValues`, and `Date()` or `new Date()` with no argument. `new Date(value)` is pure and passes. These are reported when reached through `globalThis` too (`globalThis.Date.now()`). Node's `crypto` module is not banned as a whole, since hashing is pure, but its sources of randomness and key generation (`randomUUID`, `randomBytes`, `randomInt`, `randomFillSync`, the `generate*` functions, `getRandomValues`, `webcrypto`; the full list is `NODE_CRYPTO_NONDETERMINISTIC` in the options file) are reported as named imports from it and as members of a default or namespace import of it;
|
|
743
751
|
- an `async` function or method, `await`, and `for await`.
|
|
744
752
|
|
|
745
|
-
`allowImports` lists specifiers, in the [specifier pattern](#specifier-patterns)
|
|
753
|
+
`allowImports` lists specifiers, in the [specifier pattern](#specifier-patterns) form, exempted from the module ban. Every entry must select a banned module, so an entry that could never apply fails when the config is created. `noControlFlow: true` adds `exadev/no-control-flow` to the same block, for a module that should hold lookup tables and nothing else.
|
|
746
754
|
|
|
747
755
|
It is one rule under one name, not a `no-restricted-imports`, `no-restricted-globals` and `no-restricted-syntax` recipe, because flat config replaces a rule's options when a later block sets the same rule for the same files. A consumer's own `no-restricted-syntax` block over the pure files would silently drop a recipe's entries; it cannot drop these. The bans are syntactic. They keep a module from reaching for ambient state directly, and they do not follow an alias (`const { random } = Math`) or prove the module deterministic. A repository needing a different list writes its own `no-restricted-*` blocks.
|
|
748
756
|
|
|
@@ -888,7 +896,7 @@ The rule reports without offering a fix when it cannot show that the pieces join
|
|
|
888
896
|
|
|
889
897
|
It also reports without a fix when the template sits where TypeScript needs its literal type, which the joined string (a plain `string`) does not have: under `as const`, `as`, `satisfies` or a type assertion (also through an enclosing array or object), as a string enum member's initialiser, or as the initialiser of a declaration with a type annotation. Without type information it cannot see the contextual type of a call argument or a return value, so those are still fixed.
|
|
890
898
|
|
|
891
|
-
A tagged template is never reported. The tag receives the literal's pieces, so joining them would change what it is called with, and multi-line `sql`, `css` or `markdown` templates are the point of the syntax. There is therefore no option listing tags to ignore. `allowFiles` takes globs in the [file glob
|
|
899
|
+
A tagged template is never reported. The tag receives the literal's pieces, so joining them would change what it is called with, and multi-line `sql`, `css` or `markdown` templates are the point of the syntax. There is therefore no option listing tags to ignore. `allowFiles` takes globs in the [file glob form](#file-level-rules) for files whose multi-line strings are intended (fixtures, prompts):
|
|
892
900
|
|
|
893
901
|
```ts
|
|
894
902
|
'exadev/no-multiline-template-literal': ['error', { allowFiles: ['src/prompts/**', 'test/fixtures/**'] }]
|
|
@@ -916,13 +924,86 @@ export default defineConfig(
|
|
|
916
924
|
);
|
|
917
925
|
```
|
|
918
926
|
|
|
919
|
-
The preset needs `@eslint/markdown`, an optional peer (`pnpm add -D @eslint/markdown`), and throws with the install command when it cannot be resolved. It registers the plugin under `markdown` and sets the language to `markdown/gfm`. `files` follows the [file glob
|
|
927
|
+
The preset needs `@eslint/markdown`, an optional peer (`pnpm add -D @eslint/markdown`), and throws with the install command when it cannot be resolved. It registers the plugin under `markdown` and sets the language to `markdown/gfm`. `files` follows the [file glob form](#file-level-rules) with a leading `!` excluding. Whether ESLint lints `**/*.md` at all is a repository-wide decision the preset cannot make: a `.gitignore`-derived or other `ignores` entry that hides those files still hides them.
|
|
920
928
|
|
|
921
929
|
Each entry is a `depth` from 1 to 6 (`#` to `######`) and the `text` of the heading. A document is missing an entry unless some heading of that depth has that text once the heading is flattened from its inline children and read the way a renderer shows it: emphasis, strong, links and code spans contribute their text, an image its alternative text, a hard break a space, and raw inline HTML nothing. The text is trimmed and each run of whitespace collapses to one space, on both sides, so a two-line Setext heading equals its one-line spelling. Matching is otherwise exact, case included. A heading anywhere in the document counts, inside a blockquote or a list as well. Each missing entry is reported once, at the top of the file, after the whole document has been read.
|
|
922
930
|
|
|
923
931
|
The entries are a list rather than a single `{ depth, text }` because flat config replaces a rule's options when a later block sets the same rule for the same files, so a second required heading could not be added by enabling the rule again.
|
|
924
932
|
|
|
925
|
-
Frontmatter is parsed as a node of its own (`yaml` by default, or `frontmatter: 'toml'` or `'json'`). Without that, a leading `---` block is read as a thematic break followed by a Setext heading made of the block's first line, which could satisfy or spoil a check; the preset always sets it. A repository that wires the rule by hand sets `languageOptions: { frontmatter: 'yaml' }` on its own block for the same reason. The frontmatter is never treated as a heading, so `title: Usage` does not satisfy a required `Usage`.
|
|
933
|
+
Frontmatter is parsed as a node of its own (`yaml` by default, or `frontmatter: 'toml'` or `'json'`). Without that, a leading `---` block is read as a thematic break followed by a Setext heading made of the block's first line, which could satisfy or spoil a check; the preset always sets it. A repository that wires the rule by hand sets `languageOptions: { frontmatter: 'yaml' }` on its own block for the same reason. A SKILL.md is always YAML, so when `frontmatter` is `'toml'` or `'json'` and `files` also selects SKILL.md files, the agent skills blocks must come later than this one (`exadevConfig()` orders them so; with the standalone `agentSkillsConfig()`, spread it after `markdownHeadingsConfig()`), because flat config takes the later block's frontmatter format. The frontmatter is never treated as a heading, so `title: Usage` does not satisfy a required `Usage`.
|
|
934
|
+
|
|
935
|
+
## Agent skills and plugin marketplaces
|
|
936
|
+
|
|
937
|
+
A repository that publishes agent skills, or a Claude Code plugin marketplace, depends on conventions that its tools enforce by silently ignoring what breaks them. The [skills CLI](https://github.com/vercel-labs/skills) ([latest archived copy](https://web.archive.org/web/https://github.com/vercel-labs/skills)) drops every skill whose `name` is already taken and skips a marketplace entry whose `source` does not start with `./`, and Claude Code prefers a plugin's own `version` over its marketplace entry's. None of these is an error anywhere, so a skill or plugin just goes missing or announces the wrong release. `agentSkillsConfig` (or `exadevConfig({ agentSkills })`) lints the files involved with four rules:
|
|
938
|
+
|
|
939
|
+
```ts
|
|
940
|
+
import { defineConfig } from 'eslint/config';
|
|
941
|
+
import { agentSkillsConfig, exadevConfig } from '@exadev/eslint-config';
|
|
942
|
+
|
|
943
|
+
export default defineConfig(
|
|
944
|
+
...exadevConfig({ agentSkills: false }),
|
|
945
|
+
...agentSkillsConfig({
|
|
946
|
+
skillFiles: ['skills/*/SKILL.md', 'plugins/*/skills/*/SKILL.md', '!plugins/legacy/**'],
|
|
947
|
+
pluginFiles: ['plugins/*/.claude-plugin/plugin.json'],
|
|
948
|
+
}),
|
|
949
|
+
);
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
`agentSkills: false` is part of the example. In a repository with a skills layout `exadevConfig()` detects it and adds the default-glob blocks itself, and flat config applies every block that selects a file, so a `!` exclusion in the later `agentSkillsConfig` block would not take back a rule the earlier block already applied to the excluded plugin.
|
|
953
|
+
|
|
954
|
+
The same object goes to `exadevConfig({ agentSkills: { ... } })`, which replaces the default globs instead of adding blocks beside them, so it needs no `agentSkills: false`. With no argument the defaults apply, and `exadevConfig()` needs no option at all in a repository that has a marketplace or a skills directory:
|
|
955
|
+
|
|
956
|
+
| Value | `options.agentSkills` |
|
|
957
|
+
| --- | --- |
|
|
958
|
+
| `true` | Force on with the default globs, throwing if `@eslint/markdown` or `@eslint/json` isn't resolvable |
|
|
959
|
+
| an options object | Force on, with the globs it gives in place of the defaults |
|
|
960
|
+
| `false` | Force off, always `[]`, no resolution attempted |
|
|
961
|
+
| `undefined` / omitted | Auto-detect (the default): on if the working directory has a `.claude-plugin/marketplace.json`, a `skills/<name>/SKILL.md` at its root or a `plugins/<plugin>/skills/<name>/SKILL.md`, using whichever of the two optional peers resolves; a symbolic link to a directory counts as that directory |
|
|
962
|
+
|
|
963
|
+
| Option | Default | Selects |
|
|
964
|
+
| --- | --- | --- |
|
|
965
|
+
| `skillFiles` | `**/skills/*/SKILL.md` | The SKILL.md files checked by `skill-frontmatter`, and the files `skill-name-unique` compares names across. |
|
|
966
|
+
| `marketplaceFiles` | `.claude-plugin/marketplace.json` | The marketplace manifests checked by `marketplace-manifest`. |
|
|
967
|
+
| `pluginFiles` | `**/.claude-plugin/plugin.json` | The plugin manifests checked by `plugin-manifest`. |
|
|
968
|
+
|
|
969
|
+
Each is a list of globs relative to ESLint's working directory, matched exactly as ESLint matches a config's `files` (see [file-level rules](#file-level-rules): minimatch, with `dot: true`, a leading `!` excluding), so extglob, POSIX classes and braces mean here what they mean to ESLint and the duplicate check selects the files ESLint lints. A glob minimatch cannot compile is rejected when the option is read, naming the option and the glob. Whether ESLint lints these files at all stays a repository-wide decision: a `.gitignore`-derived or other `ignores` entry that hides them still hides them. SKILL.md files are linted under `markdown/gfm` with `frontmatter: 'yaml'`, so the frontmatter is a node of its own, and the manifests under `json/json`. `exadevConfig()` already canonicalises JSON (see [RFC 8785 canonical JSON formatting](#rfc-8785-canonical-json-formatting)): `eslint --fix` rewrites the layout of `.claude-plugin/marketplace.json` and `plugin.json` to the pretty-printed form and sorts their keys under `json/sort-keys`. That is independent of these rules, which read the manifests by key and do not depend on key order. The rules need `@eslint/markdown` and `@eslint/json`, both optional peers (`pnpm add -D @eslint/markdown @eslint/json`); a forced feature throws with the install command when one is missing, and auto-detection builds the blocks whose peer resolves.
|
|
970
|
+
|
|
971
|
+
### Skills
|
|
972
|
+
|
|
973
|
+
`exadev/skill-frontmatter` requires a SKILL.md to start with YAML frontmatter that is a mapping and holds:
|
|
974
|
+
|
|
975
|
+
- `name`: a non-empty string equal to the name of the directory holding the SKILL.md, made of lower-case letters and digits (of any script, as the reference validator `skills-ref` accepts them) in hyphen-separated runs with no leading, trailing or doubled hyphen, within the length limit of the [Agent Skills specification](https://agentskills.io/specification) ([latest archived copy](https://web.archive.org/web/https://agentskills.io/specification)). The skills CLI skips a skill with no name, and a name that differs from the directory leaves one skill answering to two identifiers: the slash command Claude Code derives from the directory and the name other agents list. The name is stripped of surrounding whitespace first (a quoted `" a"` is `a`), and the name and the directory name are both compared after NFKC normalisation, as in the reference validator ([`skills-ref`](https://github.com/agentskills/agentskills/blob/main/skills-ref/src/skills_ref/validator.py), [latest archived copy](https://web.archive.org/web/https://github.com/agentskills/agentskills/blob/main/skills-ref/src/skills_ref/validator.py)), so a decomposed directory name such as macOS stores equals the composed name in the frontmatter, and the length counts code points of the normalised name.
|
|
976
|
+
- `description`: a string with text in it (an empty or whitespace-only one is reported) within the specification's length limit, counted on the value as written. Agents match it against the task and load every description into context up front.
|
|
977
|
+
- `metadata.internal`: a boolean when present, since `true` is the value the skills CLI reads to hide a skill.
|
|
978
|
+
|
|
979
|
+
The two limits are `MAX_SKILL_DESCRIPTION_LENGTH` and `MAX_SKILL_NAME_LENGTH` in [`src/rules/skill-frontmatter.ts`](src/rules/skill-frontmatter.ts), counted in characters (code points). Every other key is accepted (`argument-hint`, `disable-model-invocation`, `allowed-tools`, `model`, `metadata` and whatever the specification adds next), and every problem is reported rather than the first. A file with no frontmatter, frontmatter that is not valid YAML (an alias with no anchor, such as `description: *Required*`, included), or frontmatter that is not a mapping is reported once, and never stops the other skills being linted.
|
|
980
|
+
|
|
981
|
+
`exadev/skill-name-unique` reports a skill whose name another SKILL.md in the repository also defines, naming the other paths. The skills CLI de-duplicates skills by name and silently drops all but one, so a copied SKILL.md whose name was never changed disappears from `skills add` with no error. The other files are found by scanning ESLint's working directory (skipping `node_modules` and `.git`) for the files `skillFiles` selects. Wildcards and `**` match a dot-prefixed directory such as `.agents` just as they do when ESLint selects the files to lint, so a duplicate is reported from both sides wherever the two copies sit. The file being linted is compared by its own text, not its copy on disk, and a file that is not on disk yet is compared like any other. A scanned file that resolves to the linted file through a symbolic link is the same skill, not a duplicate (a skill directory linked into a plugin), though the scan itself never follows a link. Names are compared trimmed of surrounding whitespace and otherwise exactly, as the skills CLI de-duplicates them: a quoted `" a"` and `a` are one name, while a fullwidth `\uff41` or a decomposed accent is another. A SKILL.md that declares no usable name is not counted, since `skill-frontmatter` already reports it. A directory the process is not permitted to list counts as empty, since ESLint lints nothing in it either; a SKILL.md the scan finds but cannot read is an error naming the file, because a duplicate could hide in it.
|
|
982
|
+
|
|
983
|
+
The scan runs once per working directory, file selection and ignore list for the life of the process, and is never invalidated. Its cache belongs to the rule, which every `ESLint` instance in the process shares, so a long-lived process such as an editor's language server, or a script that creates a new `ESLint` instance after editing skills, keeps comparing against the names it first read until the process restarts: a skill whose name was edited is still compared by its old name, and a skill added later is not seen. A cached path that no longer exists (a skill directory renamed or removed) is skipped rather than reported as a duplicate, and never makes the rule throw. ESLint's `--cache` adds to this: it keys on the linted file and the configuration only, so a SKILL.md whose own text has not changed is not linted again after another skill is added, renamed or removed. Delete the cache file after changing the set of skills; a cold cache is unaffected. This is the same limitation the [workspace architecture](#workspace-architecture) graph has, and it is deliberate: ESLint gives a rule no signal that a run has started, so detecting a change would mean rescanning the tree for every SKILL.md linted, and checking only the files already seen would miss a newly added copy and leave a staleness harder to predict than one stated plainly.
|
|
984
|
+
|
|
985
|
+
The scan counts only files ESLint lints, because a copy ESLint ignores could never be fixed from where the finding shows. A rule cannot ask ESLint what it ignores, so the rule takes an `ignores` option holding entries of a config's `ignores`, evaluated in order with the last entry that selects a path deciding it and `!` bringing a path back, an entry with a leading slash selecting nothing (as in ESLint, where such a pattern never matches a relative path), and an ignored directory is not entered. `exadevConfig()` passes the ignores it derives from `.gitignore` (see [Gitignore-derived ignores](#gitignore-derived-ignores)), so a build copy such as `dist/skills/a/SKILL.md` is not a duplicate of `skills/a/SKILL.md`, and passes none when `gitignore` is off, since ESLint then lints that copy too. Ignores you write in your own config blocks, and everything under the standalone `agentSkillsConfig`, are not visible to the rule: exclude those directories with a `!` glob in `skillFiles` (`'!dist/**'`), which the scan applies as well as ESLint.
|
|
986
|
+
|
|
987
|
+
### Marketplaces and plugins
|
|
988
|
+
|
|
989
|
+
`exadev/marketplace-manifest` checks a Claude Code marketplace and the plugins it lists, reading the marketplace root as the directory that holds `.claude-plugin`:
|
|
990
|
+
|
|
991
|
+
- `name` and `owner.name` are non-empty strings, and `plugins` is an array of objects, each with a non-empty `name`, unique across the list, and a `source` that is a string or an object.
|
|
992
|
+
- An entry has no `version` key. Claude Code ignores a marketplace entry's version once the plugin's own `plugin.json` sets one, so the two silently disagree.
|
|
993
|
+
- An entry has no `skills` key. Claude Code scans a plugin's `skills/` directory by default, so declaring it lists each skill twice.
|
|
994
|
+
- A string `source` starts with `./`. The skills CLI skips every other form when it searches a marketplace for skills.
|
|
995
|
+
- The directory a local `source` names holds `.claude-plugin/plugin.json`, whose `name` equals the entry's, and no two entries name the same directory.
|
|
996
|
+
- Every directory directly under `plugins/` that holds a `.claude-plugin/plugin.json` is listed by some entry. A symbolic link to a directory counts as a directory here, so a plugin kept elsewhere and linked in is held to the same rule.
|
|
997
|
+
|
|
998
|
+
An object `source` (`github`, `git-subdir` and the like) points at another repository and is checked only for being an object. A sibling `plugin.json` or `package.json` is read as JSON with comments, trailing commas and a leading byte order mark allowed, as the other JSON-reading rules here read a file they do not lint, so a file an editor accepts is not reported for it. One that is not valid JSON at all is reported on the marketplace entry that lists it (`marketplace-manifest`) or on the manifest it sits beside (`plugin-manifest`), with the parse error, instead of aborting the run; its own lint reports the syntax error where it is linted.
|
|
999
|
+
|
|
1000
|
+
`exadev/plugin-manifest` checks each plugin manifest:
|
|
1001
|
+
|
|
1002
|
+
- `name` equals the plugin directory's name, the directory holding `.claude-plugin`, since the marketplace entry, the `/<plugin>:<skill>` command prefix and the directory are read as one identifier. A plugin at ESLint's working directory, or in a directory that also holds a `.claude-plugin/marketplace.json` (the plugin a marketplace entry with source `./` names, wherever the marketplace sits), is exempt from the match, since that directory is the checkout or marketplace folder and is named by wherever it was cloned; its `name` need only be a non-empty string.
|
|
1003
|
+
- There is no `skills` key, for the same double-scan reason as in the marketplace.
|
|
1004
|
+
- When a `package.json` beside `.claude-plugin` declares a `version`, the manifest's `version` equals it, and both values are reported when they differ. A release tool bumps `package.json`, and Claude Code prefers the manifest's version, so a manifest left behind keeps announcing the old release.
|
|
1005
|
+
|
|
1006
|
+
The version check is the convention of marketplaces that release each plugin from its own `package.json`; a plugin with no `package.json` beside it is never asked for a version. Claude Code's own references are the authority for field names: [plugin manifest](https://code.claude.com/docs/en/plugins/manifest-reference) ([latest archived copy](https://web.archive.org/web/https://code.claude.com/docs/en/plugins/manifest-reference)) and [marketplace](https://code.claude.com/docs/en/plugins/marketplace-reference) ([latest archived copy](https://web.archive.org/web/https://code.claude.com/docs/en/plugins/marketplace-reference)).
|
|
926
1007
|
|
|
927
1008
|
## Timeout races
|
|
928
1009
|
|
|
@@ -977,7 +1058,7 @@ A fallback on a value that really can be absent turns the absence into a plausib
|
|
|
977
1058
|
|
|
978
1059
|
A catch that does anything else with the error (logs it, rethrows it, returns something computed from it) is not reported. The fix is to model absence as `T | undefined` and handle it where it is meaningful, or to let the failure reach the caller.
|
|
979
1060
|
|
|
980
|
-
It is opt-in: it is in neither `plugin.configs.recommended` nor the default export, because a configuration reader legitimately treats a missing optional field as empty, and that boundary differs per repository. `allow` lists the files where the fallback is at such a boundary, each with a `reason` (required, so the exemption explains itself where it is configured, since `noInlineConfig` leaves no comment to do so). `files` follows the [file glob
|
|
1061
|
+
It is opt-in: it is in neither `plugin.configs.recommended` nor the default export, because a configuration reader legitimately treats a missing optional field as empty, and that boundary differs per repository. `allow` lists the files where the fallback is at such a boundary, each with a `reason` (required, so the exemption explains itself where it is configured, since `noInlineConfig` leaves no comment to do so). `files` follows the [file glob form](#file-level-rules), so a glob without a `/` names a file at any depth.
|
|
981
1062
|
|
|
982
1063
|
Two shapes are reported although they are sometimes right, and the fix for both is an `allow` entry. A predicate that turns a failure into `false` (`try { JSON.parse(text); return true; } catch { return false; }`) is reported as a catch that returns a fixed value. `.catch` is matched on any receiver, so a schema fallback such as Zod's `z.string().catch('')` is reported as a promise handler when it is written as a function returning a fixed value.
|
|
983
1064
|
|
|
@@ -1066,7 +1147,7 @@ Each rule scopes itself by filename, so it can be switched on for every JavaScri
|
|
|
1066
1147
|
| `stryker-break-threshold`, `stryker-thresholds-order` | `stryker*.config.*`, `stryker*.conf.*` and the dot-prefixed `.stryker.config.*` and `.stryker.conf.*` |
|
|
1067
1148
|
| `playwright-config` | `playwright*.config.*` |
|
|
1068
1149
|
|
|
1069
|
-
Every rule takes a `files` option, a glob or list of globs in the [file glob
|
|
1150
|
+
Every rule takes a `files` option, a glob or list of globs in the [file glob form](#file-level-rules), that replaces the built-in scope; a glob without a `/` names a file at any depth.
|
|
1070
1151
|
|
|
1071
1152
|
**Only what the file spells out is judged.** A rule follows the default export (`export default`, `export =` or `module.exports =`) through an object literal, an array, a call to a config helper (`defineConfig`, `defineProject`, `defineWorkspace`, `mergeConfig`), the returned value of a function, either branch of a conditional, and a top-level `const`. A value that is the result of any other call, an import, a spread or a computed key is not visible, and the rule stays silent about it instead of guessing. A package config that merges a shared base and sets only a few keys is therefore judged only on those keys, which is why the checks that need a complete picture run on the base file itself (`vitest-coverage-config`, and the `base` option of `stryker-break-threshold`).
|
|
1072
1153
|
|
|
@@ -1665,14 +1746,15 @@ pnpm build
|
|
|
1665
1746
|
- `buildJsonLanguageBlock` assembles the `exadev` and `json` plugin registration for a `files` list under `json/json` or `json/jsonc`; the `json/jsonc` form also admits trailing commas, matching the JSONC config in [`src/json-canonical.ts`](src/json-canonical.ts). `JSONC_FILE_GLOBS` (`tsconfig*.json`, `turbo.json`, `*.jsonc`) lists the files that carry comments, so a rule targeting them must be wired under `json/jsonc`, and a rule meant for both languages declares both in `meta.languages`.
|
|
1666
1747
|
- `buildPackageJsonKeyOrderConfig` and `buildWorkspaceArchitectureConfig` are built on it; a new JSON rule family follows the same shape, taking its tri-state `enabled` option from `exadevConfig` and choosing `requireJsonPlugin` (forced on) or `tryResolveJsonPlugin` (auto-detected).
|
|
1667
1748
|
- [`src/rules/file-scope.ts`](src/rules/file-scope.ts) lets a rule scope itself by filename, so it still behaves when a consumer wires it onto a wider `files` list.
|
|
1668
|
-
- `createFileScope(globs)` returns a `(filename, cwd) => boolean` matching the file relative to `cwd`
|
|
1669
|
-
- `fileGlobsSchema` and `readFileGlobs` are the option schema and runtime validator for a glob list; a list needs at least one include
|
|
1749
|
+
- `createFileScope(globs)` returns a `(filename, cwd) => boolean` matching the file relative to `cwd` with minimatch (a leading `!` excludes, wildcards never matching dot-prefixed names). A file outside `cwd` (its relative path starts with `..`) is never in scope, whatever the patterns. Build it once per `create()`.
|
|
1750
|
+
- `fileGlobsSchema` and `readFileGlobs` are the option schema and runtime validator for a glob list; a list needs at least one include, may not repeat a glob, and every glob must compile in minimatch.
|
|
1670
1751
|
- [`src/rules/file-entry.ts`](src/rules/file-entry.ts) is the shared option handling for the per-file rules (`required-exports`, `required-imports`, `import-policy`, `filename-pattern`): `createEntryScope` widens a glob without a `/` to any depth on top of `createFileScope`, and the `read*` helpers validate entries and reject unknown keys.
|
|
1671
1752
|
- [`src/rules/specifier-match.ts`](src/rules/specifier-match.ts) matches import specifiers against patterns for `required-imports` and `import-policy`, reusing the path matcher `createPathMatcher` in `file-scope.ts`.
|
|
1672
1753
|
- [`src/import-policy.ts`](src/import-policy.ts) builds the `importPolicyConfig` block from the validated policies in [`src/rules/import-policy-options.ts`](src/rules/import-policy-options.ts), which the rule reads with the same reader.
|
|
1673
1754
|
- [`src/pure-modules.ts`](src/pure-modules.ts) builds the `pureModulesConfig` block: `files` becomes the block's `files` and `ignores`, and the rule options are read by [`src/rules/pure-module-options.ts`](src/rules/pure-module-options.ts), which also holds the ban lists. [`src/rules/pure-module.ts`](src/rules/pure-module.ts) reuses `moduleReferenceOf` from `import-policy.ts` so every import syntax is recognised the same way.
|
|
1674
1755
|
- [`src/test-hygiene.ts`](src/test-hygiene.ts) builds the `testHygieneConfig` blocks: it resolves the optional `@vitest/eslint-plugin` through `tryRequire`, checks the three rules it relies on exist, and emits the vitest rules and `exadev/injected-test-hygiene` per list of globs, plus `exadev/non-vacuous-guard` for guard files. Tests supply a plugin through `assembleTestHygieneConfig`, so the public options carry no resolver seam. [`src/config-globs.ts`](src/config-globs.ts) turns a validated glob list into a block's `files` and `ignores`, shared with the pure-module builder.
|
|
1675
1756
|
- [`src/markdown-headings.ts`](src/markdown-headings.ts) builds the `markdownHeadingsConfig` block. It resolves the optional `@eslint/markdown` through [`src/markdown-plugin.ts`](src/markdown-plugin.ts), which unwraps the ES module namespace `require()` returns the way `json-plugin.ts` does for `@eslint/json`, and fixes the language and the frontmatter option the rule depends on. [`src/rules/markdown-required-heading.ts`](src/rules/markdown-required-heading.ts) is typed against `@eslint/markdown`'s own rule definition, like the JSON rules.
|
|
1757
|
+
- [`src/agent-skills.ts`](src/agent-skills.ts) builds the `agentSkillsConfig` blocks and holds the tri-state behaviour of `exadevConfig({ agentSkills })`. It resolves `@eslint/markdown` and `@eslint/json` through [`src/markdown-plugin.ts`](src/markdown-plugin.ts) and [`src/json-language-config.ts`](src/json-language-config.ts), building each block only when its peer resolves unless the feature is forced, and detects a marketplace or skills layout through the same injectable `WorkspaceFs` the cross-file rules use. The four rules read the filesystem through that seam too, so their tests supply an in-memory tree from [`src/rules/memory-fs.ts`](src/rules/memory-fs.ts). [`src/rules/skill-document.ts`](src/rules/skill-document.ts) is the frontmatter reading `skill-frontmatter` and `skill-name-unique` share, [`src/rules/claude-plugin-json.ts`](src/rules/claude-plugin-json.ts) the JSON reading the two manifest rules share, and `yaml` parses the frontmatter.
|
|
1676
1758
|
- [`src/rules/file-reference.ts`](src/rules/file-reference.ts) is the option shape for a rule that reads another file: `{ path, relativeTo? }`, resolved against the linted file's directory (`file`, the default) or the workspace root (`root`, found the way the workspace architecture rules find it).
|
|
1677
1759
|
- `readReferencedJson` parses the target as JSONC through [`src/rules/jsonc.ts`](src/rules/jsonc.ts) (comments, trailing commas and a leading byte order mark), returning `undefined` for a missing file and throwing, naming the path, for one that does not parse.
|
|
1678
1760
|
- [`src/turbo-config.ts`](src/turbo-config.ts) builds the turbo blocks (`buildTurboConfig` internally, `turboConfig` publicly) from the one shared options object in [`src/rules/turbo-options.ts`](src/rules/turbo-options.ts).
|
|
@@ -1685,12 +1767,13 @@ pnpm build
|
|
|
1685
1767
|
- [`src/rules/run-tracker.ts`](src/rules/run-tracker.ts) decides which lint carries a finding that belongs to a set of files rather than to one, so `require-compiler-options` reports once per run; it reads run boundaries from the order of the lints, as [Compiler options](#compiler-options) describes.
|
|
1686
1768
|
- [`src/rules/tsconfig-attribution.ts`](src/rules/tsconfig-attribution.ts) works out which tsconfig governs a linted file, attributing a program with no tsconfig to the one the file was last linted under while that tsconfig still lists it.
|
|
1687
1769
|
- [`src/create-config.ts`](src/create-config.ts) is config assembly's single source of truth.
|
|
1688
|
-
- `exadevConfig(options, ...userConfigs)` concatenates, in order: `buildGitignoreConfig`, `recommendedTypeChecked`, `jsdocAndTsdoc`, `jsonCanonicalConfig`, `stylisticCommentsConfig`, `buildReactConfig`, `buildNextjsConfig`, `buildPackageJsonKeyOrderConfig` (each tri-state builder fed its matching option), `buildWorkspaceArchitectureConfig` (only when `workspaceArchitecture` is given; there is no auto-detected default), `buildTurboConfig` (only when `turbo` is given, likewise), `buildImportPolicyConfig` (only when `importPolicies` is given, likewise), `buildPureModulesConfig` (only when `pureModules` is given, likewise), `buildTestHygieneConfig` (only when `testHygiene` is given, likewise), `buildMarkdownHeadingsConfig` (only when `markdownHeadings` is given, likewise), `buildToolingWiringConfig` (only when `toolingWiring` is given, likewise), and any trailing user configs.
|
|
1770
|
+
- `exadevConfig(options, ...userConfigs)` concatenates, in order: `buildGitignoreConfig`, `recommendedTypeChecked`, `jsdocAndTsdoc`, `jsonCanonicalConfig`, `stylisticCommentsConfig`, `buildReactConfig`, `buildNextjsConfig`, `buildPackageJsonKeyOrderConfig` (each tri-state builder fed its matching option), `buildWorkspaceArchitectureConfig` (only when `workspaceArchitecture` is given; there is no auto-detected default), `buildTurboConfig` (only when `turbo` is given, likewise), `buildImportPolicyConfig` (only when `importPolicies` is given, likewise), `buildPureModulesConfig` (only when `pureModules` is given, likewise), `buildTestHygieneConfig` (only when `testHygiene` is given, likewise), `buildMarkdownHeadingsConfig` (only when `markdownHeadings` is given, likewise), `buildAgentSkillsConfig` (tri-state, placed after the headings block so that a SKILL.md stays YAML whatever format `markdownHeadings` sets), `buildToolingWiringConfig` (only when `toolingWiring` is given, likewise), and any trailing user configs.
|
|
1689
1771
|
- `defaultConfig` is `exadevConfig()` evaluated once, eagerly, at module load.
|
|
1690
1772
|
- [`src/robustness-rules.ts`](src/robustness-rules.ts) holds `UNTYPED_ROBUSTNESS_RULES`, the core ESLint rules that need no type information, spread into both `plugin.configs.recommended` and the type-checked block so the two cannot drift apart.
|
|
1691
1773
|
- [`src/stylistic-comments.ts`](src/stylistic-comments.ts) builds `stylisticCommentsConfig`: the hand-picked `@stylistic/eslint-plugin` rules plus this package's own `exadev/multiline-comment-style` and `exadev/prefer-doc-comment`, in two blocks (one scoped to every JS/TS file, one scoped to JSX files only for the three JSX-specific rules). See [Stylistic comment, class-member and JSX rules](#stylistic-comment-class-member-and-jsx-rules).
|
|
1692
1774
|
- [`src/index.ts`](src/index.ts) is the entry point, still a pure re-export barrel:
|
|
1693
1775
|
```ts
|
|
1776
|
+
export { agentSkillsConfig } from './agent-skills';
|
|
1694
1777
|
export { defaultConfig as default, exadevConfig } from './create-config';
|
|
1695
1778
|
export { importPolicyConfig } from './import-policy';
|
|
1696
1779
|
export { markdownHeadingsConfig } from './markdown-headings';
|
|
@@ -1701,6 +1784,7 @@ pnpm build
|
|
|
1701
1784
|
export { turboConfig } from './turbo-config';
|
|
1702
1785
|
export { assertEslintConfig, verifyEslintConfig } from './verify-eslint';
|
|
1703
1786
|
export { workspaceArchitectureConfig } from './workspace-architecture';
|
|
1787
|
+
export type { AgentSkillsOptions } from './agent-skills';
|
|
1704
1788
|
export type { MarkdownFrontmatter, MarkdownHeadingsOptions } from './markdown-headings';
|
|
1705
1789
|
export type { PureModulesOptions } from './pure-modules';
|
|
1706
1790
|
export type { TestHygieneOptions } from './test-hygiene';
|