exadev-eslint-config 2.31.2 → 2.32.1

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 CHANGED
@@ -372,9 +372,9 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
372
372
  | `no-enum-reverse-lookup-widening` | suggestion | **A numeric enum's reverse lookup can silently type as `string` for an out-of-range index.** Indexing a numeric enum's reverse mapping (`Direction[n]`) with a bare (non-literal) `number`, or with a different enum's member, types as plain `string` for any index, including one outside the enum's actual members, where it genuinely returns `undefined` at runtime — `tsc` does not range-check even a numeric literal index here. When the indexed expression is the init of a variable with an explicit `: string` annotation, a suggestion widens it to `: string \| undefined`, forcing later uses as a bare `string` to surface as real compile errors; every other syntactic position gets a plain report with no fix, and no case gets a full `--fix` autofix. Requires type information — only in the default (type-checked) export, not `plugin.configs.recommended`. |
373
373
  | `prefer-numeric-sort-compare` | suggestion | **`.sort()` on a number array sorts lexicographically by default.** A deliberately narrow addition alongside [`@typescript-eslint/require-array-sort-compare`](https://typescript-eslint.io/rules/require-array-sort-compare/) (which already flags any bare `.sort()`/`.toSorted()` except on a plain string array, with no fix): when the array's element type is definitively `number`, a suggestion offers an ascending compare function (`(a, b) => a - b`), since the default comparator sorts lexicographically (`[1, 2, 10].sort()` becomes `[1, 10, 2]`). Not a full autofix — descending order is a real, if less common, alternative intent. Requires type information — only in the default (type-checked) export, not `plugin.configs.recommended`, since it needs the checker to confirm the array's element type. |
374
374
  | `prefer-options-object-param` | suggestion | **A run of 2+ trailing optional parameters (`?`-marked or default-valued) should be bundled into one destructured `options` parameter.** Without this, a caller needing only the last optional parameter must still pass `undefined` for every optional parameter before it (the motivating case: a 10-parameter constructor with 8 trailing optional ones). The threshold is configurable (`{ minTrailingOptional }`, default `2`). A suggestion (not a full autofix) rewrites the parameter list and inserts a `const { ... } = options ?? {};` destructure as the body's first statement; call sites are never rewritten, since the signature edit alone turns every stale positional call site into a real compile error. Still reported, but with no suggestion offered, whenever collapsing the run would not be safe/mechanical: a parameter property in the run, a parameter with no simple resolvable name and explicit type of its own (including a destructured parameter, or one typed only through a separately-declared function-type alias), a decorated parameter, a rest parameter anywhere in the full parameter list, no `BlockStatement` body at all (an arrow's expression body, or any declaration-only signature — including an ambient `.d.ts` function, an interface method/construct signature, or an abstract/ambient class method), a `@param` JSDoc tag naming a parameter in the run, or a parameter/function-scope-local variable already named `options`. Complements `max-params` (see "Individual rule tuning" above), which catches the different shape (excessive REQUIRED parameters) this rule is deliberately blind to. Requires no type information — registered in both `plugin.configs.recommended` and the default (type-checked) export, like `prefer-readonly-array-param` above; unlike its own `prefer-readonly-object-param`/`prefer-numeric-sort-compare` siblings, which genuinely need the checker and so cannot be offered outside the default export at all. |
375
- | `prefer-doc-comment` | ✓ | **A substantial leading comment on an exported declaration should be a doc comment, not a plain `//`/`/* */` comment.** "Substantial" means 2 or more comment lines, or a single line whose text exceeds a configurable length (`{ maxLineLength }`, default `80`). Covers an exported function declaration, an exported function's own signature (a body-less `TSDeclareFunction`, an ordinary overload signature and a genuinely ambient one alike, `declare function foo(): void;` included, since TypeScript never shows either one's own implementation to callers, only the signature itself), an exported `const` declaration, whatever its initialiser (a function expression or arrow function, an ordinary value such as `export const TIMEOUT_MS = 5000;`, or none at all in an ambient `export declare const x: number;`; reported once even when the statement declares several, e.g. `export const h1 = () => {}, h2 = () => {};`), an exported class declaration, ambient (`export declare class`) or ordinary alike (TypeScript strips only an ambient class's own MEMBER bodies, never its declaration shape, so its leading comment documents the identical public contract an ordinary exported class's does), an exported class's public (non-`private`/`protected`/`#`-private) method (covering both a method's own overload signature and its trailing implementation) and public abstract method (`abstract m(): void;`, needing no equivalent overload-implementation exemption of its own, since TypeScript's own grammar never lets an abstract method carry a body at all), an exported class's public function/arrow-valued property (the class-member equivalent of the function-initialised `const` shape above), an exported interface/type-alias declaration, an exported enum declaration (`const enum` alike; never an individual member, the identical reason an interface's members are never individually checked), an exported namespace/module declaration (ambient or ordinary alike, at any nesting depth whose enclosing namespaces are all exported themselves), and a default-exported expression (`export default (): number => 1;`, `export default 42;`; a default-exported `function`/`class` declaration or body-less signature is covered by its own shape above and reported exactly once, never twice); a non-exported declaration is never reported, regardless of its comment. A TypeScript function/method overload set's own body-carrying IMPLEMENTATION signature is never reported either, however substantial its own comment: TypeScript never shows that signature to callers at all, only the separate overload signatures above it (a body-less `TSDeclareFunction` for a function, written without `declare`, or a `MethodDefinition` whose own `value` is `TSEmptyBodyFunctionExpression` for a class method) are ever checked against a call site, and both of those are themselves reported like any other declaration in this rule's scope, as the overload set's own real public contract; a comment directly above the implementation is instead internal reasoning about how the overloads are actually realised, never a public contract. The implementation is detected via ESLint's own scope analysis for a function (the overload signatures and the implementation all share one Variable) and, for a class method, via an EARLIER (source-order), same-`static`-ness, same-key signature sibling: a same-named `static`/instance pair is never one overload set to TypeScript itself, and a same-named signature declared AFTER the implementation belongs to a different, later member, never the one the implementation itself realises, so neither is ever mistaken for the implementation's own overload signature. A trailing comment on the previous line of code (`const x = 1; // note`) is never treated as the next declaration's own leading comment. A directive-shaped line (the full ESLint directive/config-comment family, `eslint-disable`/`eslint-disable-line`/`eslint-disable-next-line`/`eslint-enable`/`eslint`/`eslint-env`/`global`/`globals`/`exported`, matched case-sensitively, exactly as ESLint's own directive parser matches them, each requiring real whitespace or end-of-string immediately after the keyword rather than a bare word boundary, so an ordinary compound word such as `eslint-plugin-x` is never mistaken for one, and, on a `//` Line comment specifically, recognised only as `eslint-disable-line`/`eslint-disable-next-line`, the two labels ESLint itself honours there; every other member of the family is a directive only in Block form, matching what ESLint itself does; `@ts-expect-error`/`@ts-ignore`/`@ts-nocheck`/`@ts-check`, the `ts-*` markers matched with or without their own leading `@`; `TODO`/`FIXME`, matched case-insensitively when all-uppercase or immediately followed by `:`/`(` (`todo:`, `fixme(scope):`), never a `Todo`/`Fixme` opening ordinary prose; `prettier-ignore`; `c8 ignore`/`v8 ignore`/`istanbul ignore`/`node:coverage ignore`/`node:coverage disable`/`node:coverage enable`; `cspell:disable`/`cspell:disable-line`/`cspell:disable-next-line`/`cspell:enable` (never cspell's own `ignore`/`words` directives, which name dictionary words rather than suppressing a stretch of text); `biome-ignore`; and `#region`/`#endregion`, an editor folding marker) found anywhere in a `//` run is never itself merged into the fix: only the prose segment directly adjacent to the declaration, the run of lines after the LAST such directive, is ever reported and converted; every earlier segment, and every directive line itself, is left completely untouched, since an earlier segment sits on the far side of a directive from the declaration and documents whatever the directive itself demarcates, never the declaration below it; a directive seated directly above the declaration itself is transparent to this split rather than a boundary of it, since group assembly skips past it before the run is ever collected, so the prose above it is still reported and converted, with the directive preserved untouched in place, still functional, directly above the declaration; a directive found inside an already-consolidated Block comment leaves the whole comment alone, since a partial fix cannot safely be sliced out of one physical comment node. A `///` triple-slash reference/AMD directive (`/// <reference types="..." />`) is recognised separately, by its own un-stripped comment value rather than this same pattern, and excluded from the fix the same way. An existing `/**`-opening doc comment, or a `/*!` license/banner block, is left alone regardless of length. Autofix rewraps the comment's existing text into a `/** ... */` block, one line per original logical line as this rule's own fixer writes it, preserving each line's own indentation beyond the delimiter's single conventional space (never flattening a nested example or sub-list to its neighbours' level), and unwraps a hand-written starred block's (`/* ... */` with its own leading `* ` per line) marker rather than doubling it against the fixer's own. This describes this rule's own fixer alone, not a guarantee that `exadevConfig()`'s combined output preserves that text verbatim: a bundled sibling rule's own fixer can still rewrite it further in the same `--fix` run, which is exactly why the fix is withheld below for the two such collisions this rule already guards against. Handles the leading comment whether it is a run of `//` lines or already a bare block: a bare block may be hand-written, or, for a consumer who separately enables `@stylistic/eslint-plugin`'s own `multiline-comment-style` themselves, produced by its own bare-block fixer. Always reported when substantial, but the fix withheld, rather than silently skipping the violation, whenever the exact resulting text would contain a literal closing-comment delimiter or fails to parse as valid TSDoc (checked directly against `@microsoft/tsdoc`'s own parser, the same one `eslint-plugin-tsdoc`'s `tsdoc/syntax` validates against). Also withheld when any considered line itself begins with a literal `*` once its own leading whitespace is trimmed (a hand-written markdown bullet, `* item`, or a bare block comment whose own lines are only partially starred): spliced verbatim into this fixer's own template, that reads as a repeated delimiter on a middle line, which eslint-plugin-jsdoc's own `jsdoc/no-multi-asterisks` rule (bundled unconditionally alongside this rule, see [`jsdoc.ts`](src/jsdoc.ts)) would then strip in the very same `--fix` run, silently losing real content in a way neither this rule nor `@microsoft/tsdoc`'s own parser has any way to detect on its own. Also withheld when any considered line carries an unescaped tag-shaped `@word` mention, at the line's own start (a genuine TSDoc block or modifier tag, `@remarks`, `@param foo`, `@internal`, `@typeParam T`, `@virtual`, `@override`, ...) or immediately after whitespace anywhere further into the line (a bare mid-line mention, `tseslint.config() is @deprecated upstream`), the identical shape `jsdoc/escape-inline-tags` itself keys on: a line-start tag withholds because jsdoc.ts spreads `flat/recommended-tsdoc-error` with no `settings.jsdoc` override of its own, so eslint-plugin-jsdoc's own tag-vocabulary rules (bundled unconditionally alongside this rule too) validate a doc comment's tags against JSDoc/TypeScript's own vocabulary rather than TSDoc's, and confirmed directly (a real combined-config run, not this rule in isolation) to delete, rewrite or otherwise fight over a genuine TSDoc-only tag in the very same `--fix` run: `jsdoc/empty-tags` deletes an `@internal` tag's own description text, `jsdoc/check-tag-names` deletes `@public`/`@readonly` outright and rewrites `@typeParam` to `@template` (which then fails `tsdoc/syntax`, since `@template` is not itself valid TSDoc) and `@virtual` to `@abstract`, and `@override` triggers a live fight between `check-tag-names` and `empty-tags`. A bare mid-line mention withholds because it is worse than a sibling-fixer collision: it parses as perfectly valid TSDoc, but confirmed directly (linting a scratch file with this repo's own bundled config) that TypeScript's own JSDoc parser reads the converted mention as a REAL tag on the symbol, so a converted `is @deprecated upstream` makes every use of the symbol fail `@typescript-eslint/no-deprecated`, changing what the symbol means rather than merely its formatting. A mention wrapped in a backtick code span (`@deprecated` inside one) is exempt from both and converts safely, the backtick immediately before the `@` breaking the whitespace-preceded shape for TypeScript's tag scanning and `escape-inline-tags` alike (both confirmed directly). Withholding on every tag-shaped line, not merely a blank line adjacent to one, is deliberate: this rule has no way to enumerate in advance which further TSDoc-only tag eslint-plugin-jsdoc's own vocabulary will next disagree with, so it withholds structurally rather than adding a further one-off guard each time a new collision is found. `jsdoc.ts`'s own `check-tag-names`/TSDoc-vocabulary disagreement is itself a pre-existing gap on `main`, not caused by this rule, tracked separately. Requires no type information. Wired only through `stylisticCommentsConfig` into the default (type-checked) `exadevConfig()` output (see [Stylistic comment, class-member and JSX rules](#stylistic-comment-class-member-and-jsx-rules)), never registered in `plugin.configs.recommended`/`.barrel`, so a consumer using the lighter `extends: [plugin.configs.recommended]` option (see [The lighter option](#the-lighter-option-the-plugin-named-export)) never gets it. |
375
+ | `prefer-doc-comment` | ✓ | **A substantial leading comment on an exported declaration should be a doc comment, not a plain `//`/`/* */` comment.** "Substantial" means 2 or more comment lines, or a single line whose text exceeds a configurable length (`{ maxLineLength }`, default `80`). Covers an exported function declaration, an exported function's own signature (a body-less `TSDeclareFunction`, an ordinary overload signature and a genuinely ambient one alike, `declare function foo(): void;` included, since TypeScript never shows either one's own implementation to callers, only the signature itself), an exported `const` declaration, whatever its initialiser (a function expression or arrow function, an ordinary value such as `export const TIMEOUT_MS = 5000;`, or none at all in an ambient `export declare const x: number;`; reported once even when the statement declares several, e.g. `export const h1 = () => {}, h2 = () => {};`), an exported class declaration, ambient (`export declare class`) or ordinary alike (TypeScript strips only an ambient class's own MEMBER bodies, never its declaration shape, so its leading comment documents the identical public contract an ordinary exported class's does), an exported class's public (non-`private`/`protected`/`#`-private) method (covering both a method's own overload signature and its trailing implementation) and public abstract method (`abstract m(): void;`, needing no equivalent overload-implementation exemption of its own, since TypeScript's own grammar never lets an abstract method carry a body at all), an exported class's public function/arrow-valued property (the class-member equivalent of the function-initialised `const` shape above), an exported interface/type-alias declaration, an exported enum declaration (`const enum` alike; never an individual member, the identical reason an interface's members are never individually checked), an exported namespace/module declaration (ambient or ordinary alike, at any nesting depth whose enclosing namespaces are all exported themselves), and a default-exported expression (`export default (): number => 1;`, `export default 42;`; a default-exported `function`/`class` declaration or body-less signature is covered by its own shape above and reported exactly once, never twice); a non-exported declaration is never reported, regardless of its comment. A TypeScript function/method overload set's own body-carrying IMPLEMENTATION signature is never reported either, however substantial its own comment: TypeScript never shows that signature to callers at all, only the separate overload signatures above it (a body-less `TSDeclareFunction` for a function, written without `declare`, or a `MethodDefinition` whose own `value` is `TSEmptyBodyFunctionExpression` for a class method) are ever checked against a call site, and both of those are themselves reported like any other declaration in this rule's scope, as the overload set's own real public contract; a comment directly above the implementation is instead internal reasoning about how the overloads are actually realised, never a public contract. The implementation is detected via ESLint's own scope analysis for a function (the overload signatures and the implementation all share one Variable) and, for a class method, via an EARLIER (source-order), same-`static`-ness, same-key signature sibling: a same-named `static`/instance pair is never one overload set to TypeScript itself, and a same-named signature declared AFTER the implementation belongs to a different, later member, never the one the implementation itself realises, so neither is ever mistaken for the implementation's own overload signature. A trailing comment on the previous line of code (`const x = 1; // note`) is never treated as the next declaration's own leading comment. A directive-shaped line (the full ESLint directive/config-comment family, `eslint-disable`/`eslint-disable-line`/`eslint-disable-next-line`/`eslint-enable`/`eslint`/`eslint-env`/`global`/`globals`/`exported`, matched case-sensitively, exactly as ESLint's own directive parser matches them, each requiring real whitespace or end-of-string immediately after the keyword rather than a bare word boundary, so an ordinary compound word such as `eslint-plugin-x` is never mistaken for one, and, on a `//` Line comment specifically, recognised only as `eslint-disable-line`/`eslint-disable-next-line`, the two labels ESLint itself honours there; every other member of the family is a directive only in Block form, matching what ESLint itself does; `@ts-expect-error`/`@ts-ignore`/`@ts-nocheck`/`@ts-check`, the `ts-*` markers matched with or without their own leading `@`; `TODO`/`FIXME`, matched case-insensitively when all-uppercase or immediately followed by `:`/`(` (`todo:`, `fixme(scope):`), never a `Todo`/`Fixme` opening ordinary prose; `prettier-ignore`; `c8 ignore`/`v8 ignore`/`istanbul ignore`/`node:coverage ignore`/`node:coverage disable`/`node:coverage enable`; `cspell:disable`/`cspell:disable-line`/`cspell:disable-next-line`/`cspell:enable` (never cspell's own `ignore`/`words` directives, which name dictionary words rather than suppressing a stretch of text); `biome-ignore`; and `#region`/`#endregion`, an editor folding marker) found anywhere in a `//` run is never itself merged into the fix: only the prose segment directly adjacent to the declaration, the run of lines after the LAST such directive, is ever reported and converted; every earlier segment, and every directive line itself, is left completely untouched, since an earlier segment sits on the far side of a directive from the declaration and documents whatever the directive itself demarcates, never the declaration below it; a directive seated directly above the declaration itself is transparent to this split rather than a boundary of it, since group assembly skips past it before the run is ever collected, so the prose above it is still reported and converted, with the directive preserved untouched in place, still functional, directly above the declaration; a directive found inside an already-consolidated Block comment leaves the whole comment alone, since a partial fix cannot safely be sliced out of one physical comment node. A `///` triple-slash reference/AMD directive (`/// <reference types="..." />`) is recognised separately, by its own un-stripped comment value rather than this same pattern, and excluded from the fix the same way. An existing `/**`-opening doc comment, or a `/*!` license/banner block, is left alone regardless of length. Autofix rewraps the comment's existing text into a `/** ... */` block, one line per original logical line as this rule's own fixer writes it, preserving each line's own indentation beyond the delimiter's single conventional space (never flattening a nested example or sub-list to its neighbours' level), and unwraps a hand-written starred block's (`/* ... */` with its own leading `* ` per line) marker rather than doubling it against the fixer's own. This describes this rule's own fixer alone, not a guarantee that `exadevConfig()`'s combined output preserves that text verbatim: a bundled sibling rule's own fixer can still rewrite it further in the same `--fix` run, which is exactly why the fix is withheld below for the two such collisions this rule already guards against. Handles the leading comment whether it is a run of `//` lines or already a bare block: a bare block may be hand-written, or, for a consumer who separately enables `@stylistic/eslint-plugin`'s own `multiline-comment-style` themselves, produced by its own bare-block fixer. Always reported when substantial, but the fix withheld, rather than silently skipping the violation, whenever the exact resulting text would contain a literal closing-comment delimiter or fails to parse as valid TSDoc (checked directly against `@microsoft/tsdoc`'s own parser, the same one `eslint-plugin-tsdoc`'s `tsdoc/syntax` validates against). Also withheld when any considered line itself begins with a literal `*` once its own leading whitespace is trimmed (a hand-written markdown bullet, `* item`, or a bare block comment whose own lines are only partially starred): spliced verbatim into this fixer's own template, that reads as a repeated delimiter on a middle line, which eslint-plugin-jsdoc's own `jsdoc/no-multi-asterisks` rule (bundled unconditionally alongside this rule, see [`jsdoc.ts`](src/jsdoc.ts)) would then strip in the very same `--fix` run, silently losing real content in a way neither this rule nor `@microsoft/tsdoc`'s own parser has any way to detect on its own. Also withheld when any considered line carries an unescaped tag-shaped `@word` mention, at the line's own start (a genuine TSDoc block or modifier tag, `@remarks`, `@param foo`, `@internal`, `@typeParam T`, `@virtual`, `@override`, ...) or immediately after whitespace anywhere further into the line (a bare mid-line mention, `tseslint.config() is @deprecated upstream`), the identical shape `jsdoc/escape-inline-tags` itself keys on: a line-start tag withholds because the converted doc comment would be validated by every tag-checking sibling rule in the bundle and read by TypeScript's own JSDoc parser, and this rule cannot enumerate in advance which tag either will next object to or act on. jsdoc.ts gives `jsdoc/check-tag-names` the TSDoc vocabulary (`settings.jsdoc.tagNamePreference` entries for the TSDoc tags JSDoc spells differently, `typed: false`, and `jsdoc/empty-tags` off), so the TSDoc tags that once collided (`@internal`, `@public`, `@readonly`, `@typeParam`, `@virtual`, `@override`) survive `--fix` there, but a converted tag line such as `@deprecated` or `@internal` is still read as a REAL tag on the symbol, changing what it means rather than merely its formatting. Withholding on every tag-shaped line, not merely a blank line adjacent to one, is deliberate, so no further one-off guard is needed each time a new collision is found. Requires no type information. Wired only through `stylisticCommentsConfig` into the default (type-checked) `exadevConfig()` output (see [Stylistic comment, class-member and JSX rules](#stylistic-comment-class-member-and-jsx-rules)), never registered in `plugin.configs.recommended`/`.barrel`, so a consumer using the lighter `extends: [plugin.configs.recommended]` option (see [The lighter option](#the-lighter-option-the-plugin-named-export)) never gets it. |
376
376
  | `package-json-key-order` | ✓ | **Requires `package.json`'s keys to match `syncpack format`'s order.** See [Optional package.json key ordering](#optional-packagejson-key-ordering) — opt-in via `exadevConfig({ packageJsonKeyOrder: true })`, not part of `recommended`/`barrel`. A JSON-language rule (`@eslint/json`'s `json/json`), not a TSESLint one — needs no type information and doesn't apply to any `.ts`/`.js` file. |
377
- | `no-uphill-dependency` | | **Enforces a configured workspace's rank, rank-skip, slice and group-isolation boundaries** on every `package.json`'s declared dependencies, with documented per-edge exceptions (`allow`) and exempt target groups (`exemptTargetGroups`). See [Workspace architecture](#workspace-architecture). Opt-in via `exadevConfig({ workspaceArchitecture })` or the standalone `workspaceArchitectureConfig()`, not part of `recommended`/`barrel`. A JSON-language rule, needs no type information. |
377
+ | `no-uphill-dependency` | | **Enforces a configured workspace's rank, rank-skip, slice and group-isolation boundaries** on every `package.json`'s declared dependencies, plus selector-based dependency constraints (`dependencyConstraints`), with documented per-edge exceptions (`allow`) and exempt target groups (`exemptTargetGroups`). See [Workspace architecture](#workspace-architecture). Opt-in via `exadevConfig({ workspaceArchitecture })` or the standalone `workspaceArchitectureConfig()`, not part of `recommended`/`barrel`. A JSON-language rule, needs no type information. |
378
378
  | `no-dependency-cycle` | | **Disallows a workspace dependency that can reach back to the package declaring it.** A same-rank or same-slice dependency passes `no-uphill-dependency` while still forming a cycle, which this rule catches instead. See [Workspace architecture](#workspace-architecture). |
379
379
  | `package-name-mirrors-path` | | **Requires a workspace package's declared name to match the name its own path derives**, under the configured naming scope and separator. A package that declares no name at all is reported too (it plainly cannot mirror its path when it names nothing). Itself opt-in within workspace architecture: a no-op unless the shared `naming` option is given. See [Workspace architecture](#workspace-architecture). |
380
380
  | `package-has-files` | | **Requires configured files to exist inside every matching workspace package.** ESLint cannot report a file that does not exist, so the diagnostic is anchored on the package's own `package.json`, listing the missing paths. Entries may be globs. Opt-in: a no-op unless the shared `requiredFiles` option is given. See [Required files](#required-files). |
@@ -1066,6 +1066,7 @@ Both entry points need `@eslint/json` resolvable (an optional peer dependency of
1066
1066
  | `naming` | `{ scope?, separator? }`. Enables `package-name-mirrors-path`; omitted entirely, that rule is a no-op. |
1067
1067
  | `allow` | `{ from, to, reason }[]`, documented exceptions to `no-uphill-dependency`, one per edge. See [Documented exceptions](#documented-exceptions-and-exempt-groups). |
1068
1068
  | `exemptTargetGroups` | `{ group, fields }[]`. Edges into a package of `group`, declared only under `fields`, skip every `no-uphill-dependency` check. See [Documented exceptions](#documented-exceptions-and-exempt-groups). |
1069
+ | `dependencyConstraints` | `{ packages, allow?, deny?, reason }[]`. Limits which workspace packages a selected package may depend on, checked by `no-uphill-dependency`. See [Dependency constraints](#dependency-constraints). |
1069
1070
  | `requiredFiles` | `{ packages, files }[]`. Enables `package-has-files`. See [Required files](#required-files). |
1070
1071
  | `devOnly` | Selectors for packages that may appear only under `devDependencies`. Enables `dev-dependency-only`. See [Dev-only packages](#dev-only-packages). |
1071
1072
  | `requiredScripts` | `{ match, scripts }[]`. Enables `required-scripts`. See [Required scripts](#required-scripts). |
@@ -1074,11 +1075,11 @@ A group's own `slice` (`{ segment: N }` or `{ namePrefix: true }`) partitions it
1074
1075
 
1075
1076
  ### Package selectors
1076
1077
 
1077
- `requiredFiles`, `devOnly` and `requiredScripts` pick packages with one selector shape. A string is a regular expression tested against the package's declared name, in the same dialect as `nameRanks` (so `-testkit$`, not a glob such as `*-testkit`). An object selects by `group` (one of the declared `groups`), by `namePattern` (the same regular expression), or by both, in which case both must hold. A package that declares no name is matched by its group only; a name pattern never matches it. An invalid pattern or an undeclared group fails when the options are read, naming the option.
1078
+ `dependencyConstraints`, `requiredFiles`, `devOnly` and `requiredScripts` pick packages with one selector shape. A string is a regular expression tested against the package's declared name, in the same dialect as `nameRanks` (so `-testkit$`, not a glob such as `*-testkit`). An object selects by `group` (one of the declared `groups`), by `namePattern` (the same regular expression), or by both, in which case both must hold. A package that declares no name is matched by its group only; a name pattern never matches it. An invalid pattern or an undeclared group fails when the options are read, naming the option.
1078
1079
 
1079
1080
  ### Documented exceptions and exempt groups
1080
1081
 
1081
- `allow` names single edges by declared package name. `reason` is required and must be non-empty, so an exception always says why it exists. It excuses that edge from every `no-uphill-dependency` check (rank, rank skip, slice and isolated groups); it does not affect `no-dependency-cycle`, so an allowed edge that closes a cycle is still reported there.
1082
+ `allow` names single edges by declared package name. `reason` is required and must be non-empty, so an exception always says why it exists. It excuses that edge from every `no-uphill-dependency` check (rank, rank skip, slice, isolated groups and [dependency constraints](#dependency-constraints)); it does not affect `no-dependency-cycle`, so an allowed edge that closes a cycle is still reported there.
1082
1083
 
1083
1084
  ```ts
1084
1085
  allow: [
@@ -1095,6 +1096,43 @@ dependencyFields: ['dependencies', 'devDependencies'],
1095
1096
  exemptTargetGroups: [{ group: 'test', fields: ['devDependencies'] }],
1096
1097
  ```
1097
1098
 
1099
+ ### Dependency constraints
1100
+
1101
+ Ranks and isolated pairs cannot say that one group may depend only on certain others, or that packages of a given name inside a rank may not reach a given layer. `dependencyConstraints` states such rules directly. Each entry selects its source packages with `packages` and lists target [selectors](#package-selectors) under `allow`, `deny`, or both:
1102
+
1103
+ - `allow`: every workspace dependency of a selected package must match at least one of the selectors, otherwise it is a `constraintNotAllowed` violation.
1104
+ - `deny`: no workspace dependency of a selected package may match any of the selectors, otherwise it is a `constraintDenied` violation.
1105
+ - both: a dependency must match an `allow` selector and no `deny` selector, so "anything in `core` except its adapters" is `allow: [{ group: 'core' }], deny: ['-adapter-']`.
1106
+
1107
+ `reason` is required and non-empty, and appears in every diagnostic the entry produces. An entry with neither list, an empty list, an undeclared group or a pattern that does not compile fails when the options are read, naming the option.
1108
+
1109
+ Constraints are checked beside the rank, slice and isolation checks, not instead of them, so one edge can be reported by both. Each constraint is judged on its own: a dependency ruled out by two constraints is reported twice, once with each reason. Only workspace dependencies are constrained, as with every other check of the rule. An edge into an [exempt target group](#documented-exceptions-and-exempt-groups) skips the constraints too, and an `allow` edge excuses a constraint violation the same way it excuses a rank violation; an `allow` entry that excuses only a constraint violation is not stale. A source package that declares no name is selected by its group only, since a name pattern never matches it.
1110
+
1111
+ The three rules below are the ones that motivated the option. Targets may depend only on product packages:
1112
+
1113
+ ```ts
1114
+ dependencyConstraints: [
1115
+ { packages: { group: 'targets' }, allow: [{ group: 'product' }], reason: 'a target ships product code and nothing else' },
1116
+ ],
1117
+ ```
1118
+
1119
+ A product's application context may depend on contract packages, feature modules and vertical packages, and never on a core backend adapter. The `allow` list alone already rules the adapter out; the separate `deny` entry keeps the adapter rule in force for every product package, whatever its own `allow` list says:
1120
+
1121
+ ```ts
1122
+ dependencyConstraints: [
1123
+ { packages: '-application-context$', allow: ['-contract$', { group: 'features' }, { group: 'verticals' }], reason: 'the application context composes contracts, features and verticals' },
1124
+ { packages: { group: 'product' }, deny: [{ group: 'core', namePattern: '-adapter-' }], reason: 'product code reaches backend adapters only through a runtime package' },
1125
+ ],
1126
+ ```
1127
+
1128
+ Only product `runtime-*` packages may import core adapters. Selectors pick sources, so "only these may" is written as a deny for every other package: a negative lookahead selects each package whose name contains no `runtime-`. A `runtime-` package outside `product` would need an entry of its own naming its group (`{ group: 'features', namePattern: 'runtime-' }`), since a selector cannot negate a group:
1129
+
1130
+ ```ts
1131
+ dependencyConstraints: [
1132
+ { packages: { namePattern: '^(?!.*runtime-)' }, deny: [{ group: 'core', namePattern: '-adapter-' }], reason: 'only runtime packages wire backend adapters' },
1133
+ ],
1134
+ ```
1135
+
1098
1136
  ### Required files
1099
1137
 
1100
1138
  `requiredFiles` makes `package-has-files` report every matching package whose directory lacks a listed path. Paths are relative to the package directory and may be globs (braces, `*`, `?`, `[...]`, `**`; a wildcard never matches a dot-prefixed name, and `node_modules` is never searched). A literal path must exist; a glob must match at least one existing path. The diagnostic is anchored on the package's `package.json` and lists the missing entries in declaration order, across every requirement that selects the package. Pair it with a rule that checks a file's exports to cover a whole convention (file exists, then file exports the right names).
@@ -1240,7 +1278,7 @@ export default defineConfig(
1240
1278
 
1241
1279
  `loadSection` resolves to `undefined` when no `layout` section is defined, hence the check. `cwd` is the directory holding `exadev.config.ts` (or `exadev.layout.config.ts`). A layout that fails the section's schema throws when the config loads, with the path and the rule that failed.
1242
1280
 
1243
- The layout carries structure only: groups, ranks, slices, naming and isolated pairs. Policy stays here, so `allow`, `exemptTargetGroups`, `requiredFiles`, `devOnly` and `requiredScripts` are not part of it; add them with a spread (`workspaceArchitecture: { ...layout, allow: [...] }`).
1281
+ The layout carries structure only: groups, ranks, slices, naming and isolated pairs. Policy stays here, so `allow`, `exemptTargetGroups`, `dependencyConstraints`, `requiredFiles`, `devOnly` and `requiredScripts` are not part of it; add them with a spread (`workspaceArchitecture: { ...layout, allow: [...] }`).
1244
1282
 
1245
1283
  The ESLint config itself stays in `eslint.config.*`. Only the layout moves into `exadev.config.ts`: ESLint loads its own config file, and the unified file has no section for rules or plugins. Neither package depends on the other: install `@exadev/config` (and its `cosmiconfig` peer) in the repository, and `@exadev/eslint-config` takes the value structurally. This repository keeps `@exadev/config` as a dev dependency only, for the test that fails when either side's types drift apart.
1246
1284
 
@@ -1471,6 +1509,9 @@ pnpm build
1471
1509
  export type { MarkdownFrontmatter, MarkdownHeadingsOptions } from './markdown-headings';
1472
1510
  export type { PureModulesOptions } from './pure-modules';
1473
1511
  export type { TestHygieneOptions } from './test-hygiene';
1512
+ export type { PlaywrightWiringOptions, PublishWiringOptions, RootTool, RootWiringOptions, StrykerWiringOptions, ToolConfigsWiringOptions, ToolingWiringOptions, VitestWiringOptions } from './tooling-wiring';
1513
+ export type { EffectiveSeverity, EslintSample, EslintViolation, EslintViolationKind, RequiredSeverity, VerifyEslintOptions } from './verify-eslint';
1514
+ export type { FileRequirement, PackageCondition, PackageRequirement, PackageRequirementsOptions } from './rules/package-requirements-options';
1474
1515
  export type { ImportConfine, ImportDeny, ImportExceptEdge, ImportPolicy } from './rules/import-policy-options';
1475
1516
  export type { FilenamePatternEntry } from './rules/filename-pattern';
1476
1517
  export type { RequiredHeading } from './rules/markdown-required-heading';
@@ -1479,6 +1520,7 @@ pnpm build
1479
1520
  export type { GroupSpec, NamingOptions, RankRule, RankSkipOptions, SliceSpec, WorkspaceArchitectureOptions } from './rules/workspace-options';
1480
1521
  export type {
1481
1522
  AllowedEdge,
1523
+ DependencyConstraint,
1482
1524
  ExemptTargetGroup,
1483
1525
  PackageSelector,
1484
1526
  PackageSelectorFields,
package/dist/index.cjs CHANGED
@@ -185,7 +185,7 @@ function buildGitignoreConfig(options = {}) {
185
185
  }
186
186
  //#endregion
187
187
  //#region package.json
188
- var version = "2.31.2";
188
+ var version = "2.32.1";
189
189
  //#endregion
190
190
  //#region src/is-record.ts
191
191
  /**
@@ -782,6 +782,39 @@ function checkDependencies(selfName, self, dependencyNames, context) {
782
782
  }
783
783
  return violations;
784
784
  }
785
+ function constraintVerdict(constraint, target) {
786
+ if (constraint.allow !== void 0 && !constraint.allow.some((selector) => matchesSelector(selector, target))) return "constraintNotAllowed";
787
+ if (constraint.deny?.some((selector) => matchesSelector(selector, target)) === true) return "constraintDenied";
788
+ }
789
+ /**
790
+ * Every workspace dependency of `self` that a `dependencyConstraints` entry selecting `self` rules out: `constraintNotAllowed` when the entry has an `allow` list and the dependency matches none of it, otherwise `constraintDenied` when it matches a `deny` selector. Each constraint is judged on its own, so a dependency two constraints rule out yields two violations, each carrying its own constraint's reason. A non-workspace dependency and an exempt target are skipped, as checkDependencies skips them.
791
+ */
792
+ function checkDependencyConstraints(self, dependencyNames, context) {
793
+ if (context.constraints === void 0) return [];
794
+ const applicable = context.constraints.filter((constraint) => matchesSelector(constraint.packages, self));
795
+ return dependencyNames.flatMap((dependencyName) => {
796
+ const dependency = context.graph.get(dependencyName);
797
+ if (dependency === void 0 || context.exemptTargets?.has(dependencyName) === true) return [];
798
+ const target = {
799
+ group: dependency.group,
800
+ name: dependencyName
801
+ };
802
+ return applicable.flatMap((constraint) => {
803
+ const messageId = constraintVerdict(constraint, target);
804
+ if (messageId === void 0) return [];
805
+ return [{
806
+ dependencyName,
807
+ messageId,
808
+ data: {
809
+ self: self.displayName,
810
+ dependency: dependencyName,
811
+ dependencyGroup: dependency.group,
812
+ reason: constraint.reason
813
+ }
814
+ }];
815
+ });
816
+ });
817
+ }
785
818
  /**
786
819
  * The dependency names whose edges an `exemptTargetGroups` entry exempts: a workspace package in an exempt group, every occurrence of which is declared under one of that group's exempt fields. A name that also appears under any other configured field is not exempt, so a package cannot hide a runtime edge behind a devDependencies entry of the same name.
787
820
  */
@@ -797,7 +830,7 @@ function exemptDependencyNames(dependencies, graph, exemptions) {
797
830
  return exempt;
798
831
  }
799
832
  /**
800
- * Applies the `allow` list to the violations `selfName` produced: a violation on an edge from `selfName` to an allowed target is dropped, and every entry naming `selfName` as its source that suppressed nothing is returned as stale, either because the dependency is no longer declared or because the checks would have passed it anyway.
833
+ * Applies the `allow` list to the violations `selfName` produced (the rank, slice and isolation violations and the dependency constraint violations alike): a violation on an edge from `selfName` to an allowed target is dropped, and every entry naming `selfName` as its source that suppressed nothing is returned as stale, either because the dependency is no longer declared or because the checks would have passed it anyway.
801
834
  */
802
835
  function applyAllowList(selfName, found, allow) {
803
836
  const own = allow.filter((entry) => entry.from === selfName);
@@ -1330,6 +1363,32 @@ const workspaceConstraintOptionsSchema = {
1330
1363
  additionalProperties: false
1331
1364
  }
1332
1365
  },
1366
+ dependencyConstraints: {
1367
+ type: "array",
1368
+ items: {
1369
+ type: "object",
1370
+ properties: {
1371
+ packages: selectorSchema,
1372
+ allow: {
1373
+ type: "array",
1374
+ items: selectorSchema,
1375
+ minItems: 1
1376
+ },
1377
+ deny: {
1378
+ type: "array",
1379
+ items: selectorSchema,
1380
+ minItems: 1
1381
+ },
1382
+ reason: {
1383
+ type: "string",
1384
+ minLength: 1
1385
+ }
1386
+ },
1387
+ required: ["packages", "reason"],
1388
+ anyOf: [{ required: ["allow"] }, { required: ["deny"] }],
1389
+ additionalProperties: false
1390
+ }
1391
+ },
1333
1392
  requiredFiles: {
1334
1393
  type: "array",
1335
1394
  items: {
@@ -1450,6 +1509,40 @@ function readExemptTargetGroups(value, context) {
1450
1509
  };
1451
1510
  });
1452
1511
  }
1512
+ const CONSTRAINT_KEYS = [
1513
+ "packages",
1514
+ "allow",
1515
+ "deny",
1516
+ "reason"
1517
+ ];
1518
+ const CONSTRAINTS_OPTION = "dependencyConstraints";
1519
+ function readSelectorList(value, what, context) {
1520
+ const selectors = readArray$1(value, CONSTRAINTS_OPTION);
1521
+ if (selectors.length === 0) fail$4(CONSTRAINTS_OPTION, `${what} must not be empty.`);
1522
+ return selectors.map((selector) => readSelector(selector, CONSTRAINTS_OPTION, context));
1523
+ }
1524
+ function readDependencyConstraints(value, context) {
1525
+ return readArray$1(value, CONSTRAINTS_OPTION).map((entry) => {
1526
+ const fields = readEntry$1(entry, CONSTRAINTS_OPTION, CONSTRAINT_KEYS);
1527
+ if (fields["packages"] === void 0) fail$4(CONSTRAINTS_OPTION, "entries must select their source packages with \"packages\".");
1528
+ const packages = readSelector(fields["packages"], CONSTRAINTS_OPTION, context);
1529
+ const reason = readNonEmptyString$1(fields["reason"], CONSTRAINTS_OPTION, "\"reason\" (a constraint must say why it exists)");
1530
+ const { allow, deny } = fields;
1531
+ const denied = deny === void 0 ? void 0 : readSelectorList(deny, "\"deny\"", context);
1532
+ if (allow !== void 0) return {
1533
+ packages,
1534
+ allow: readSelectorList(allow, "\"allow\"", context),
1535
+ ...denied !== void 0 && { deny: denied },
1536
+ reason
1537
+ };
1538
+ if (denied === void 0) fail$4(CONSTRAINTS_OPTION, "entries must list \"allow\", \"deny\", or both.");
1539
+ return {
1540
+ packages,
1541
+ deny: denied,
1542
+ reason
1543
+ };
1544
+ });
1545
+ }
1453
1546
  const REQUIRED_FILES_KEYS = ["packages", "files"];
1454
1547
  function readRequiredPath(value) {
1455
1548
  const path = readNonEmptyString$1(value, "requiredFiles", "\"files\" entries");
@@ -1630,6 +1723,7 @@ const TOP_LEVEL_KEYS$1 = [
1630
1723
  "naming",
1631
1724
  "allow",
1632
1725
  "exemptTargetGroups",
1726
+ "dependencyConstraints",
1633
1727
  "requiredFiles",
1634
1728
  "devOnly",
1635
1729
  "requiredScripts"
@@ -1774,7 +1868,7 @@ function readWorkspaceArchitectureOptions(options) {
1774
1868
  groupNames: new Set(groups.map((group) => group.name)),
1775
1869
  dependencyFields: dependencyFields ?? DEFAULT_DEPENDENCY_FIELDS
1776
1870
  };
1777
- const { allow, exemptTargetGroups, requiredFiles, devOnly, requiredScripts } = options;
1871
+ const { allow, exemptTargetGroups, dependencyConstraints, requiredFiles, devOnly, requiredScripts } = options;
1778
1872
  if (isolatedGroups !== void 0) {
1779
1873
  const groupNames = new Set(groups.map((group) => group.name));
1780
1874
  for (const [first, second] of isolatedGroups) {
@@ -1794,6 +1888,7 @@ function readWorkspaceArchitectureOptions(options) {
1794
1888
  ...naming !== void 0 && { naming },
1795
1889
  ...allow !== void 0 && { allow: readAllow(allow) },
1796
1890
  ...exemptTargetGroups !== void 0 && { exemptTargetGroups: readExemptTargetGroups(exemptTargetGroups, constraintContext) },
1891
+ ...dependencyConstraints !== void 0 && { dependencyConstraints: readDependencyConstraints(dependencyConstraints, constraintContext) },
1797
1892
  ...requiredFiles !== void 0 && { requiredFiles: readRequiredFiles(requiredFiles, constraintContext) },
1798
1893
  ...devOnly !== void 0 && { devOnly: readDevOnly(devOnly, constraintContext) },
1799
1894
  ...requiredScripts !== void 0 && { requiredScripts: readRequiredScripts(requiredScripts, constraintContext) }
@@ -5723,7 +5818,7 @@ function createNoUphillDependencyRule(deps = {}) {
5723
5818
  schema: [workspaceArchitectureOptionsSchema],
5724
5819
  docs: {
5725
5820
  recommended: false,
5726
- description: "Disallow a workspace package depending on another package ranked strictly above it, on a non-exempt-rank package more than the configured distance below it, on a package in a different slice of the same or another group, or on a package in a group this workspace declares isolated from its own.",
5821
+ description: "Disallow a workspace package depending on another package ranked strictly above it, on a non-exempt-rank package more than the configured distance below it, on a package in a different slice of the same or another group, on a package in a group this workspace declares isolated from its own, or on a package a configured dependency constraint rules out.",
5727
5822
  url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/no-uphill-dependency.ts"
5728
5823
  },
5729
5824
  messages: {
@@ -5731,6 +5826,8 @@ function createNoUphillDependencyRule(deps = {}) {
5731
5826
  rankSkip: "Illegal dependency: \"{{self}}\" (rank {{selfRank}}) depends directly on \"{{dependency}}\" (rank {{dependencyRank}}), skipping too many ranks in between.",
5732
5827
  crossSlice: "Illegal dependency: \"{{self}}\" (slice \"{{selfSlice}}\") depends on \"{{dependency}}\" (slice \"{{dependencySlice}}\"). A package may depend on another in the same slice, but not a different one.",
5733
5828
  isolatedGroup: "Illegal dependency: \"{{self}}\" (group \"{{selfGroup}}\") depends on \"{{dependency}}\" (group \"{{dependencyGroup}}\"), and this workspace declares these two groups isolated from each other.",
5829
+ constraintNotAllowed: "Illegal dependency: \"{{self}}\" depends on \"{{dependency}}\" (group \"{{dependencyGroup}}\"), which matches none of the targets a dependency constraint allows it ({{reason}}).",
5830
+ constraintDenied: "Illegal dependency: \"{{self}}\" depends on \"{{dependency}}\" (group \"{{dependencyGroup}}\"), which a dependency constraint denies it ({{reason}}).",
5734
5831
  allowUndeclared: "Stale \"allow\" entry: \"{{from}}\" no longer declares a dependency on \"{{to}}\" ({{reason}}). Remove the entry.",
5735
5832
  allowUnneeded: "Stale \"allow\" entry: the edge from \"{{from}}\" to \"{{to}}\" passes every check without an exception ({{reason}}). Remove the entry.",
5736
5833
  allowSourceGone: "Stale \"allow\" entry: \"{{from}}\" is not a workspace package ({{reason}}). Remove the entry."
@@ -5755,14 +5852,24 @@ function createNoUphillDependencyRule(deps = {}) {
5755
5852
  if (self === void 0) return;
5756
5853
  const dependencies = collectTopLevelDependencies(node, resolveDependencyFields(options));
5757
5854
  const dependencyNames = [...new Set(dependencies.map((dependency) => dependency.name))];
5855
+ const exemptTargets = exemptDependencyNames(dependencies, graph.packagesByName, options.exemptTargetGroups);
5758
5856
  const found = checkDependencies(self.name, self, dependencyNames, {
5759
5857
  graph: graph.packagesByName,
5760
- exemptTargets: exemptDependencyNames(dependencies, graph.packagesByName, options.exemptTargetGroups),
5858
+ exemptTargets,
5761
5859
  ...options.rankSkip !== void 0 && { rankSkip: options.rankSkip },
5762
5860
  ...options.isolatedGroups !== void 0 && { isolatedGroups: options.isolatedGroups }
5763
5861
  });
5862
+ const constrained = checkDependencyConstraints({
5863
+ displayName: self.name,
5864
+ group: self.group,
5865
+ name: declared?.name
5866
+ }, dependencyNames, {
5867
+ graph: graph.packagesByName,
5868
+ constraints: options.dependencyConstraints,
5869
+ exemptTargets
5870
+ });
5764
5871
  const { violations, stale } = applyAllowList(self.name, {
5765
- violations: found,
5872
+ violations: [...found, ...constrained],
5766
5873
  dependencyNames
5767
5874
  }, options.allow ?? []);
5768
5875
  for (const violation of violations) {
@@ -10329,11 +10436,19 @@ const REQUIREMENTS_TIER_RULES = [
10329
10436
  "jsdoc/require-yields",
10330
10437
  "jsdoc/require-yields-type"
10331
10438
  ];
10439
+ const TSDOC_TAG_NAME_PREFERENCE = {
10440
+ defaultValue: "defaultValue",
10441
+ typeParam: "typeParam",
10442
+ virtual: "virtual"
10443
+ };
10332
10444
  const jsdocAndTsdoc = [{
10333
10445
  files: [JS_TS_FILE_PATTERNS$1],
10334
10446
  ...jsdocConfig,
10447
+ settings: { jsdoc: { tagNamePreference: TSDOC_TAG_NAME_PREFERENCE } },
10335
10448
  rules: {
10336
10449
  ...jsdocConfig.rules,
10450
+ "jsdoc/check-tag-names": ["error", { typed: false }],
10451
+ "jsdoc/empty-tags": "off",
10337
10452
  ...Object.fromEntries(REQUIREMENTS_TIER_RULES.map((rule) => [rule, "off"]))
10338
10453
  }
10339
10454
  }, {
package/dist/index.d.cts CHANGED
@@ -118,6 +118,20 @@ interface ExemptTargetGroup {
118
118
  readonly group: string;
119
119
  readonly fields: readonly string[];
120
120
  }
121
+ interface DependencyConstraintFields {
122
+ readonly packages: PackageSelector;
123
+ readonly reason: string;
124
+ }
125
+ /**
126
+ * Limits which workspace packages the packages `packages` selects may depend on. With `allow`, every workspace dependency must match at least one of its selectors; with `deny`, none may match any of its selectors; with both, a dependency must match an `allow` selector and no `deny` selector. At least one of the two is required, and `reason` must be non-empty, so a constraint always says why it exists.
127
+ */
128
+ type DependencyConstraint = DependencyConstraintFields & ({
129
+ readonly allow: readonly PackageSelector[];
130
+ readonly deny?: readonly PackageSelector[];
131
+ } | {
132
+ readonly allow?: never;
133
+ readonly deny: readonly PackageSelector[];
134
+ });
121
135
  interface RequiredFiles {
122
136
  readonly packages: PackageSelector;
123
137
  readonly files: readonly string[];
@@ -368,6 +382,7 @@ interface WorkspaceArchitectureOptions {
368
382
  readonly naming?: NamingOptions;
369
383
  readonly allow?: readonly AllowedEdge[];
370
384
  readonly exemptTargetGroups?: readonly ExemptTargetGroup[];
385
+ readonly dependencyConstraints?: readonly DependencyConstraint[];
371
386
  readonly requiredFiles?: readonly RequiredFiles[];
372
387
  readonly devOnly?: readonly PackageSelector[];
373
388
  readonly requiredScripts?: readonly RequiredScripts[];
@@ -509,4 +524,4 @@ interface RequiredImportsEntry {
509
524
  readonly call?: boolean;
510
525
  }
511
526
  //#endregion
512
- export { type AggregateTaskOptions, type AllowedBoundariesIgnore, type AllowedEdge, type BoundaryGroup, type EffectiveSeverity, type EslintSample, type EslintViolation, type EslintViolationKind, type ExemptTargetGroup, type FileRequirement, type FilenamePatternEntry, type GroupSpec, type ImportConfine, type ImportDeny, type ImportExceptEdge, type ImportPolicy, type MarkdownFrontmatter, type MarkdownHeadingsOptions, type NamingOptions, type PackageCondition, type PackageRequirement, type PackageRequirementsOptions, type PackageSelector, type PackageSelectorFields, type PlaywrightWiringOptions, type PublishWiringOptions, type PureModulesOptions, type RankRule, type RankSkipOptions, type RequiredExportsEntry, type RequiredFiles, type RequiredHeading, type RequiredImportsEntry, type RequiredScripts, type RequiredSeverity, type RootTool, type RootWiringOptions, type ScriptContent, type ScriptRequirement, type SliceSpec, type StrykerWiringOptions, type TaskGraphRequirement, type TestHygieneOptions, type ToolConfigsWiringOptions, type ToolingWiringOptions, type TurboBoundariesOptions, type TurboDelegate, type TurboHygieneOptions, type TurboOptions, type VerifyEslintOptions, type VitestWiringOptions, type WorkspaceArchitectureOptions, assertEslintConfig, defaultConfig as default, exadevConfig, importPolicyConfig, markdownHeadingsConfig, publicPlugin as plugin, pureModulesConfig, testHygieneConfig, toolingWiringConfig, turboConfig, verifyEslintConfig, workspaceArchitectureConfig };
527
+ export { type AggregateTaskOptions, type AllowedBoundariesIgnore, type AllowedEdge, type BoundaryGroup, type DependencyConstraint, type EffectiveSeverity, type EslintSample, type EslintViolation, type EslintViolationKind, type ExemptTargetGroup, type FileRequirement, type FilenamePatternEntry, type GroupSpec, type ImportConfine, type ImportDeny, type ImportExceptEdge, type ImportPolicy, type MarkdownFrontmatter, type MarkdownHeadingsOptions, type NamingOptions, type PackageCondition, type PackageRequirement, type PackageRequirementsOptions, type PackageSelector, type PackageSelectorFields, type PlaywrightWiringOptions, type PublishWiringOptions, type PureModulesOptions, type RankRule, type RankSkipOptions, type RequiredExportsEntry, type RequiredFiles, type RequiredHeading, type RequiredImportsEntry, type RequiredScripts, type RequiredSeverity, type RootTool, type RootWiringOptions, type ScriptContent, type ScriptRequirement, type SliceSpec, type StrykerWiringOptions, type TaskGraphRequirement, type TestHygieneOptions, type ToolConfigsWiringOptions, type ToolingWiringOptions, type TurboBoundariesOptions, type TurboDelegate, type TurboHygieneOptions, type TurboOptions, type VerifyEslintOptions, type VitestWiringOptions, type WorkspaceArchitectureOptions, assertEslintConfig, defaultConfig as default, exadevConfig, importPolicyConfig, markdownHeadingsConfig, publicPlugin as plugin, pureModulesConfig, testHygieneConfig, toolingWiringConfig, turboConfig, verifyEslintConfig, workspaceArchitectureConfig };
package/dist/index.d.ts CHANGED
@@ -118,6 +118,20 @@ interface ExemptTargetGroup {
118
118
  readonly group: string;
119
119
  readonly fields: readonly string[];
120
120
  }
121
+ interface DependencyConstraintFields {
122
+ readonly packages: PackageSelector;
123
+ readonly reason: string;
124
+ }
125
+ /**
126
+ * Limits which workspace packages the packages `packages` selects may depend on. With `allow`, every workspace dependency must match at least one of its selectors; with `deny`, none may match any of its selectors; with both, a dependency must match an `allow` selector and no `deny` selector. At least one of the two is required, and `reason` must be non-empty, so a constraint always says why it exists.
127
+ */
128
+ type DependencyConstraint = DependencyConstraintFields & ({
129
+ readonly allow: readonly PackageSelector[];
130
+ readonly deny?: readonly PackageSelector[];
131
+ } | {
132
+ readonly allow?: never;
133
+ readonly deny: readonly PackageSelector[];
134
+ });
121
135
  interface RequiredFiles {
122
136
  readonly packages: PackageSelector;
123
137
  readonly files: readonly string[];
@@ -368,6 +382,7 @@ interface WorkspaceArchitectureOptions {
368
382
  readonly naming?: NamingOptions;
369
383
  readonly allow?: readonly AllowedEdge[];
370
384
  readonly exemptTargetGroups?: readonly ExemptTargetGroup[];
385
+ readonly dependencyConstraints?: readonly DependencyConstraint[];
371
386
  readonly requiredFiles?: readonly RequiredFiles[];
372
387
  readonly devOnly?: readonly PackageSelector[];
373
388
  readonly requiredScripts?: readonly RequiredScripts[];
@@ -509,4 +524,4 @@ interface RequiredImportsEntry {
509
524
  readonly call?: boolean;
510
525
  }
511
526
  //#endregion
512
- export { type AggregateTaskOptions, type AllowedBoundariesIgnore, type AllowedEdge, type BoundaryGroup, type EffectiveSeverity, type EslintSample, type EslintViolation, type EslintViolationKind, type ExemptTargetGroup, type FileRequirement, type FilenamePatternEntry, type GroupSpec, type ImportConfine, type ImportDeny, type ImportExceptEdge, type ImportPolicy, type MarkdownFrontmatter, type MarkdownHeadingsOptions, type NamingOptions, type PackageCondition, type PackageRequirement, type PackageRequirementsOptions, type PackageSelector, type PackageSelectorFields, type PlaywrightWiringOptions, type PublishWiringOptions, type PureModulesOptions, type RankRule, type RankSkipOptions, type RequiredExportsEntry, type RequiredFiles, type RequiredHeading, type RequiredImportsEntry, type RequiredScripts, type RequiredSeverity, type RootTool, type RootWiringOptions, type ScriptContent, type ScriptRequirement, type SliceSpec, type StrykerWiringOptions, type TaskGraphRequirement, type TestHygieneOptions, type ToolConfigsWiringOptions, type ToolingWiringOptions, type TurboBoundariesOptions, type TurboDelegate, type TurboHygieneOptions, type TurboOptions, type VerifyEslintOptions, type VitestWiringOptions, type WorkspaceArchitectureOptions, assertEslintConfig, defaultConfig as default, exadevConfig, importPolicyConfig, markdownHeadingsConfig, publicPlugin as plugin, pureModulesConfig, testHygieneConfig, toolingWiringConfig, turboConfig, verifyEslintConfig, workspaceArchitectureConfig };
527
+ export { type AggregateTaskOptions, type AllowedBoundariesIgnore, type AllowedEdge, type BoundaryGroup, type DependencyConstraint, type EffectiveSeverity, type EslintSample, type EslintViolation, type EslintViolationKind, type ExemptTargetGroup, type FileRequirement, type FilenamePatternEntry, type GroupSpec, type ImportConfine, type ImportDeny, type ImportExceptEdge, type ImportPolicy, type MarkdownFrontmatter, type MarkdownHeadingsOptions, type NamingOptions, type PackageCondition, type PackageRequirement, type PackageRequirementsOptions, type PackageSelector, type PackageSelectorFields, type PlaywrightWiringOptions, type PublishWiringOptions, type PureModulesOptions, type RankRule, type RankSkipOptions, type RequiredExportsEntry, type RequiredFiles, type RequiredHeading, type RequiredImportsEntry, type RequiredScripts, type RequiredSeverity, type RootTool, type RootWiringOptions, type ScriptContent, type ScriptRequirement, type SliceSpec, type StrykerWiringOptions, type TaskGraphRequirement, type TestHygieneOptions, type ToolConfigsWiringOptions, type ToolingWiringOptions, type TurboBoundariesOptions, type TurboDelegate, type TurboHygieneOptions, type TurboOptions, type VerifyEslintOptions, type VitestWiringOptions, type WorkspaceArchitectureOptions, assertEslintConfig, defaultConfig as default, exadevConfig, importPolicyConfig, markdownHeadingsConfig, publicPlugin as plugin, pureModulesConfig, testHygieneConfig, toolingWiringConfig, turboConfig, verifyEslintConfig, workspaceArchitectureConfig };
package/dist/index.js CHANGED
@@ -150,7 +150,7 @@ function buildGitignoreConfig(options = {}) {
150
150
  }
151
151
  //#endregion
152
152
  //#region package.json
153
- var version = "2.31.2";
153
+ var version = "2.32.1";
154
154
  //#endregion
155
155
  //#region src/is-record.ts
156
156
  /**
@@ -747,6 +747,39 @@ function checkDependencies(selfName, self, dependencyNames, context) {
747
747
  }
748
748
  return violations;
749
749
  }
750
+ function constraintVerdict(constraint, target) {
751
+ if (constraint.allow !== void 0 && !constraint.allow.some((selector) => matchesSelector(selector, target))) return "constraintNotAllowed";
752
+ if (constraint.deny?.some((selector) => matchesSelector(selector, target)) === true) return "constraintDenied";
753
+ }
754
+ /**
755
+ * Every workspace dependency of `self` that a `dependencyConstraints` entry selecting `self` rules out: `constraintNotAllowed` when the entry has an `allow` list and the dependency matches none of it, otherwise `constraintDenied` when it matches a `deny` selector. Each constraint is judged on its own, so a dependency two constraints rule out yields two violations, each carrying its own constraint's reason. A non-workspace dependency and an exempt target are skipped, as checkDependencies skips them.
756
+ */
757
+ function checkDependencyConstraints(self, dependencyNames, context) {
758
+ if (context.constraints === void 0) return [];
759
+ const applicable = context.constraints.filter((constraint) => matchesSelector(constraint.packages, self));
760
+ return dependencyNames.flatMap((dependencyName) => {
761
+ const dependency = context.graph.get(dependencyName);
762
+ if (dependency === void 0 || context.exemptTargets?.has(dependencyName) === true) return [];
763
+ const target = {
764
+ group: dependency.group,
765
+ name: dependencyName
766
+ };
767
+ return applicable.flatMap((constraint) => {
768
+ const messageId = constraintVerdict(constraint, target);
769
+ if (messageId === void 0) return [];
770
+ return [{
771
+ dependencyName,
772
+ messageId,
773
+ data: {
774
+ self: self.displayName,
775
+ dependency: dependencyName,
776
+ dependencyGroup: dependency.group,
777
+ reason: constraint.reason
778
+ }
779
+ }];
780
+ });
781
+ });
782
+ }
750
783
  /**
751
784
  * The dependency names whose edges an `exemptTargetGroups` entry exempts: a workspace package in an exempt group, every occurrence of which is declared under one of that group's exempt fields. A name that also appears under any other configured field is not exempt, so a package cannot hide a runtime edge behind a devDependencies entry of the same name.
752
785
  */
@@ -762,7 +795,7 @@ function exemptDependencyNames(dependencies, graph, exemptions) {
762
795
  return exempt;
763
796
  }
764
797
  /**
765
- * Applies the `allow` list to the violations `selfName` produced: a violation on an edge from `selfName` to an allowed target is dropped, and every entry naming `selfName` as its source that suppressed nothing is returned as stale, either because the dependency is no longer declared or because the checks would have passed it anyway.
798
+ * Applies the `allow` list to the violations `selfName` produced (the rank, slice and isolation violations and the dependency constraint violations alike): a violation on an edge from `selfName` to an allowed target is dropped, and every entry naming `selfName` as its source that suppressed nothing is returned as stale, either because the dependency is no longer declared or because the checks would have passed it anyway.
766
799
  */
767
800
  function applyAllowList(selfName, found, allow) {
768
801
  const own = allow.filter((entry) => entry.from === selfName);
@@ -1295,6 +1328,32 @@ const workspaceConstraintOptionsSchema = {
1295
1328
  additionalProperties: false
1296
1329
  }
1297
1330
  },
1331
+ dependencyConstraints: {
1332
+ type: "array",
1333
+ items: {
1334
+ type: "object",
1335
+ properties: {
1336
+ packages: selectorSchema,
1337
+ allow: {
1338
+ type: "array",
1339
+ items: selectorSchema,
1340
+ minItems: 1
1341
+ },
1342
+ deny: {
1343
+ type: "array",
1344
+ items: selectorSchema,
1345
+ minItems: 1
1346
+ },
1347
+ reason: {
1348
+ type: "string",
1349
+ minLength: 1
1350
+ }
1351
+ },
1352
+ required: ["packages", "reason"],
1353
+ anyOf: [{ required: ["allow"] }, { required: ["deny"] }],
1354
+ additionalProperties: false
1355
+ }
1356
+ },
1298
1357
  requiredFiles: {
1299
1358
  type: "array",
1300
1359
  items: {
@@ -1415,6 +1474,40 @@ function readExemptTargetGroups(value, context) {
1415
1474
  };
1416
1475
  });
1417
1476
  }
1477
+ const CONSTRAINT_KEYS = [
1478
+ "packages",
1479
+ "allow",
1480
+ "deny",
1481
+ "reason"
1482
+ ];
1483
+ const CONSTRAINTS_OPTION = "dependencyConstraints";
1484
+ function readSelectorList(value, what, context) {
1485
+ const selectors = readArray$1(value, CONSTRAINTS_OPTION);
1486
+ if (selectors.length === 0) fail$4(CONSTRAINTS_OPTION, `${what} must not be empty.`);
1487
+ return selectors.map((selector) => readSelector(selector, CONSTRAINTS_OPTION, context));
1488
+ }
1489
+ function readDependencyConstraints(value, context) {
1490
+ return readArray$1(value, CONSTRAINTS_OPTION).map((entry) => {
1491
+ const fields = readEntry$1(entry, CONSTRAINTS_OPTION, CONSTRAINT_KEYS);
1492
+ if (fields["packages"] === void 0) fail$4(CONSTRAINTS_OPTION, "entries must select their source packages with \"packages\".");
1493
+ const packages = readSelector(fields["packages"], CONSTRAINTS_OPTION, context);
1494
+ const reason = readNonEmptyString$1(fields["reason"], CONSTRAINTS_OPTION, "\"reason\" (a constraint must say why it exists)");
1495
+ const { allow, deny } = fields;
1496
+ const denied = deny === void 0 ? void 0 : readSelectorList(deny, "\"deny\"", context);
1497
+ if (allow !== void 0) return {
1498
+ packages,
1499
+ allow: readSelectorList(allow, "\"allow\"", context),
1500
+ ...denied !== void 0 && { deny: denied },
1501
+ reason
1502
+ };
1503
+ if (denied === void 0) fail$4(CONSTRAINTS_OPTION, "entries must list \"allow\", \"deny\", or both.");
1504
+ return {
1505
+ packages,
1506
+ deny: denied,
1507
+ reason
1508
+ };
1509
+ });
1510
+ }
1418
1511
  const REQUIRED_FILES_KEYS = ["packages", "files"];
1419
1512
  function readRequiredPath(value) {
1420
1513
  const path = readNonEmptyString$1(value, "requiredFiles", "\"files\" entries");
@@ -1595,6 +1688,7 @@ const TOP_LEVEL_KEYS$1 = [
1595
1688
  "naming",
1596
1689
  "allow",
1597
1690
  "exemptTargetGroups",
1691
+ "dependencyConstraints",
1598
1692
  "requiredFiles",
1599
1693
  "devOnly",
1600
1694
  "requiredScripts"
@@ -1739,7 +1833,7 @@ function readWorkspaceArchitectureOptions(options) {
1739
1833
  groupNames: new Set(groups.map((group) => group.name)),
1740
1834
  dependencyFields: dependencyFields ?? DEFAULT_DEPENDENCY_FIELDS
1741
1835
  };
1742
- const { allow, exemptTargetGroups, requiredFiles, devOnly, requiredScripts } = options;
1836
+ const { allow, exemptTargetGroups, dependencyConstraints, requiredFiles, devOnly, requiredScripts } = options;
1743
1837
  if (isolatedGroups !== void 0) {
1744
1838
  const groupNames = new Set(groups.map((group) => group.name));
1745
1839
  for (const [first, second] of isolatedGroups) {
@@ -1759,6 +1853,7 @@ function readWorkspaceArchitectureOptions(options) {
1759
1853
  ...naming !== void 0 && { naming },
1760
1854
  ...allow !== void 0 && { allow: readAllow(allow) },
1761
1855
  ...exemptTargetGroups !== void 0 && { exemptTargetGroups: readExemptTargetGroups(exemptTargetGroups, constraintContext) },
1856
+ ...dependencyConstraints !== void 0 && { dependencyConstraints: readDependencyConstraints(dependencyConstraints, constraintContext) },
1762
1857
  ...requiredFiles !== void 0 && { requiredFiles: readRequiredFiles(requiredFiles, constraintContext) },
1763
1858
  ...devOnly !== void 0 && { devOnly: readDevOnly(devOnly, constraintContext) },
1764
1859
  ...requiredScripts !== void 0 && { requiredScripts: readRequiredScripts(requiredScripts, constraintContext) }
@@ -5688,7 +5783,7 @@ function createNoUphillDependencyRule(deps = {}) {
5688
5783
  schema: [workspaceArchitectureOptionsSchema],
5689
5784
  docs: {
5690
5785
  recommended: false,
5691
- description: "Disallow a workspace package depending on another package ranked strictly above it, on a non-exempt-rank package more than the configured distance below it, on a package in a different slice of the same or another group, or on a package in a group this workspace declares isolated from its own.",
5786
+ description: "Disallow a workspace package depending on another package ranked strictly above it, on a non-exempt-rank package more than the configured distance below it, on a package in a different slice of the same or another group, on a package in a group this workspace declares isolated from its own, or on a package a configured dependency constraint rules out.",
5692
5787
  url: "https://github.com/ExaDev/eslint-config/blob/main/src/rules/no-uphill-dependency.ts"
5693
5788
  },
5694
5789
  messages: {
@@ -5696,6 +5791,8 @@ function createNoUphillDependencyRule(deps = {}) {
5696
5791
  rankSkip: "Illegal dependency: \"{{self}}\" (rank {{selfRank}}) depends directly on \"{{dependency}}\" (rank {{dependencyRank}}), skipping too many ranks in between.",
5697
5792
  crossSlice: "Illegal dependency: \"{{self}}\" (slice \"{{selfSlice}}\") depends on \"{{dependency}}\" (slice \"{{dependencySlice}}\"). A package may depend on another in the same slice, but not a different one.",
5698
5793
  isolatedGroup: "Illegal dependency: \"{{self}}\" (group \"{{selfGroup}}\") depends on \"{{dependency}}\" (group \"{{dependencyGroup}}\"), and this workspace declares these two groups isolated from each other.",
5794
+ constraintNotAllowed: "Illegal dependency: \"{{self}}\" depends on \"{{dependency}}\" (group \"{{dependencyGroup}}\"), which matches none of the targets a dependency constraint allows it ({{reason}}).",
5795
+ constraintDenied: "Illegal dependency: \"{{self}}\" depends on \"{{dependency}}\" (group \"{{dependencyGroup}}\"), which a dependency constraint denies it ({{reason}}).",
5699
5796
  allowUndeclared: "Stale \"allow\" entry: \"{{from}}\" no longer declares a dependency on \"{{to}}\" ({{reason}}). Remove the entry.",
5700
5797
  allowUnneeded: "Stale \"allow\" entry: the edge from \"{{from}}\" to \"{{to}}\" passes every check without an exception ({{reason}}). Remove the entry.",
5701
5798
  allowSourceGone: "Stale \"allow\" entry: \"{{from}}\" is not a workspace package ({{reason}}). Remove the entry."
@@ -5720,14 +5817,24 @@ function createNoUphillDependencyRule(deps = {}) {
5720
5817
  if (self === void 0) return;
5721
5818
  const dependencies = collectTopLevelDependencies(node, resolveDependencyFields(options));
5722
5819
  const dependencyNames = [...new Set(dependencies.map((dependency) => dependency.name))];
5820
+ const exemptTargets = exemptDependencyNames(dependencies, graph.packagesByName, options.exemptTargetGroups);
5723
5821
  const found = checkDependencies(self.name, self, dependencyNames, {
5724
5822
  graph: graph.packagesByName,
5725
- exemptTargets: exemptDependencyNames(dependencies, graph.packagesByName, options.exemptTargetGroups),
5823
+ exemptTargets,
5726
5824
  ...options.rankSkip !== void 0 && { rankSkip: options.rankSkip },
5727
5825
  ...options.isolatedGroups !== void 0 && { isolatedGroups: options.isolatedGroups }
5728
5826
  });
5827
+ const constrained = checkDependencyConstraints({
5828
+ displayName: self.name,
5829
+ group: self.group,
5830
+ name: declared?.name
5831
+ }, dependencyNames, {
5832
+ graph: graph.packagesByName,
5833
+ constraints: options.dependencyConstraints,
5834
+ exemptTargets
5835
+ });
5729
5836
  const { violations, stale } = applyAllowList(self.name, {
5730
- violations: found,
5837
+ violations: [...found, ...constrained],
5731
5838
  dependencyNames
5732
5839
  }, options.allow ?? []);
5733
5840
  for (const violation of violations) {
@@ -10294,11 +10401,19 @@ const REQUIREMENTS_TIER_RULES = [
10294
10401
  "jsdoc/require-yields",
10295
10402
  "jsdoc/require-yields-type"
10296
10403
  ];
10404
+ const TSDOC_TAG_NAME_PREFERENCE = {
10405
+ defaultValue: "defaultValue",
10406
+ typeParam: "typeParam",
10407
+ virtual: "virtual"
10408
+ };
10297
10409
  const jsdocAndTsdoc = [{
10298
10410
  files: [JS_TS_FILE_PATTERNS$1],
10299
10411
  ...jsdocConfig,
10412
+ settings: { jsdoc: { tagNamePreference: TSDOC_TAG_NAME_PREFERENCE } },
10300
10413
  rules: {
10301
10414
  ...jsdocConfig.rules,
10415
+ "jsdoc/check-tag-names": ["error", { typed: false }],
10416
+ "jsdoc/empty-tags": "off",
10302
10417
  ...Object.fromEntries(REQUIREMENTS_TIER_RULES.map((rule) => [rule, "off"]))
10303
10418
  }
10304
10419
  }, {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "exadev-eslint-config",
3
3
  "description": "Shared custom ESLint rules and plugin for ExaDev projects",
4
- "version": "2.31.2",
4
+ "version": "2.32.1",
5
5
  "dependencies": {
6
6
  "@eslint/js": "^10.0.1",
7
7
  "@microsoft/tsdoc": "0.17.0",