diff --git a/README.md b/README.md index dcdcc0a..38db10c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # eslint-plugin-angular-class-ordering -ESLint plugin that keeps **Angular class members** (fields and methods) in a consistent order inside `@Component`, `@Directive`, `@Injectable`, and `@Pipe` classes. It understands modern Angular APIs (`inject`, signal-based `input` / `output` / `model`, `signal`, `computed`, queries, and common decorators) and includes an **auto-fix** that rewrites the class body. +ESLint plugin focused on **`member-ordering`**: it keeps **Angular class members** (fields and methods) in a consistent order inside `@Component`, `@Directive`, `@Injectable`, and `@Pipe` classes. It understands modern Angular APIs (`inject`, signal-based `input` / `output` / `model`, `signal`, `computed`, queries, and common decorators) and includes **auto-fix** for layout. + +Two **optional** rules ship with the same package — **`prefer-inject-function`** (constructor DI → `inject()`, with fix) and **`forbid-nested-super-injections`** (subclass `super()` / field-init ordering; warn-only). They are **not** part of the default preset so installing the plugin only turns on **member ordering** unless you enable the others yourself. ## Requirements @@ -34,12 +36,26 @@ module.exports = [ 'angular-class-ordering': angularClassOrdering, }, rules: { - 'angular-class-ordering/member-ordering': 'error', + ...angularClassOrdering.configs.recommended.rules, }, }, ]; ``` +The bundled **`recommended`** config enables only **`member-ordering`** at **error**. It does **not** enable `prefer-inject-function` or `forbid-nested-super-injections`, so you do not get unexpected constructor refactors from `--fix` until you opt in. + +To opt in to the inject-related rules (typical pairing: `prefer-inject-function` as **error**, `forbid-nested-super-injections` as **warn**), add them next to `recommended`: + +```javascript +rules: { + ...angularClassOrdering.configs.recommended.rules, + 'angular-class-ordering/prefer-inject-function': 'error', + 'angular-class-ordering/forbid-nested-super-injections': 'warn', +}, +``` + +For **`prefer-inject-function`**, set **`autofix: false`** when you want diagnostics without `--fix` rewrites (for example at `warn` severity). ESLint does not pass severity into rule implementations, so this must be configured explicitly. See [prefer-inject-function](docs/rules/prefer-inject-function.md#options) for options and examples. + Override rule options: ```javascript @@ -73,7 +89,9 @@ module.exports = { ## Rule documentation -See [docs/rules/member-ordering.md](docs/rules/member-ordering.md). +- [member-ordering](docs/rules/member-ordering.md) — class member order and auto-fix layout (**on** in `recommended`) +- [prefer-inject-function](docs/rules/prefer-inject-function.md) — prefer `inject()` over constructor DI; options `decorators`, `autofix` (**opt-in**) +- [forbid-nested-super-injections](docs/rules/forbid-nested-super-injections.md) — `super()` / field-init ordering; options `decorators` (**opt-in**) ## Scripts (development) @@ -81,6 +99,7 @@ See [docs/rules/member-ordering.md](docs/rules/member-ordering.md). npm test npm run test:watch npm run lint +npm run lint:fix npm run format:check ``` diff --git a/docs/rules/forbid-nested-super-injections.md b/docs/rules/forbid-nested-super-injections.md new file mode 100644 index 0000000..30aa1bc --- /dev/null +++ b/docs/rules/forbid-nested-super-injections.md @@ -0,0 +1,342 @@ +# `forbid-nested-super-injections` + +Warns when a **constructor DI parameter** on a **subclass** cannot be moved to an **`inject()` field** because it is used **before** field initializers are valid — typically inside **`super(...)`** or in code that runs before the first `super()` call. + +In a derived class, instance fields initialized with `inject()` run **after** `super()` completes. Any use of that dependency **during** `super(...)` (or earlier in the constructor) must stay a **constructor parameter** until the base class API is refactored. + +This rule is **not** enabled by the plugin’s **`recommended`** preset (that preset only enables [`member-ordering`](./member-ordering.md)). Enable it explicitly when you also use [`prefer-inject-function`](./prefer-inject-function.md). + +**Quick navigation:** [Examples](#examples) · [When a parameter is “unsafe”](#when-a-parameter-is-unsafe) · [Config](#config-examples) + +## What gets flagged + +- Subclasses **`extends`** a base **and** use a decorated Angular class (see `decorators` option). +- A constructor parameter is **DI** (parameter property or `@Inject` from `@angular/core` — same shape as [`prefer-inject-function`](./prefer-inject-function.md)). +- A **read** of that parameter is classified as **unsafe** (see [When a parameter is “unsafe”](#when-a-parameter-is-unsafe) and the examples below). + +Plain TypeScript classes **without** the configured Angular decorators are **not** analyzed, even if they use `super(arg)`. + +## Interaction with `prefer-inject-function` + +- **`forbid-nested-super-injections`** reports **only** parameters that are **unsafe** for field `inject()`. +- **`prefer-inject-function`** reports the **remaining** safe DI parameters in the **same** constructor (often with auto-fix). + +You can see a **warning** on one parameter and **errors** (with fixes) on siblings in one constructor. + +### What to do about the warning + +Refactor the **base** class so the dependency is no longer required through `super(...)` — for example by moving it to **`inject()`** on the parent and exposing a no-arg (or slimmer) constructor. Then the subclass parameter can usually be migrated by **`prefer-inject-function`**. + +**Before (subclass must forward `ApiClient` into `super`):** + +```ts +import { Component, Injectable } from '@angular/core'; + +@Injectable() +class ApiClient {} + +class BasePage { + constructor(protected readonly api: ApiClient) {} +} + +@Component({ template: '' }) +export class ChildPage extends BasePage { + constructor(private readonly api: ApiClient) { + super(api); + } +} +``` + +**After (parent holds `ApiClient` via `inject()`; subclass DI can move to fields):** + +```ts +import { Component, Injectable, inject } from '@angular/core'; + +@Injectable() +class ApiClient {} + +class BasePage { + protected readonly api = inject(ApiClient); + + constructor() {} +} + +@Component({ template: '' }) +export class ChildPage extends BasePage { + constructor() { + super(); + } +} +``` + +(You would then run `prefer-inject-function` on `ChildPage` if you add new DI there.) + +## Rule details + +- **Type**: problem +- **Fixable**: no + +## Options + +Single options object. + +### `decorators` + +Class-level decorator names that enable this rule for a class. + +- Type: `string[]` +- Default: `["Component", "Directive", "Injectable", "Pipe"]` + +(Same default as `prefer-inject-function` so the two rules stay aligned.) + +## Examples + +### Unsafe: dependency passed into `super(...)` + +**Reported** (`forbidNestedSuperInjections`). `prefer-inject-function` does **not** migrate `d`. + +```ts +import { Component } from '@angular/core'; + +class Base {} +class D {} + +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + super(d); + } +} +``` + +### Unsafe: read before `super()` completes + +**Reported** — `console.log(d)` runs before `super()`. + +```ts +import { Component } from '@angular/core'; + +class Base {} +class D {} + +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + console.log(d); + super(); + } +} +``` + +### Unsafe: two parameters both passed to `super()` + +Both parameters are reported. + +```ts +import { Component } from '@angular/core'; + +class Base {} +class A {} +class B {} + +@Component({ template: '' }) +export class X extends Base { + constructor( + private a: A, + private b: B, + ) { + super(a, b); + } +} +``` + +### Unsafe: `@Inject` parameter forwarded into `super()` + +```ts +import { Component, Inject } from '@angular/core'; + +class Base {} +const TOKEN = {}; + +@Component({ template: '' }) +export class X extends Base { + constructor(@Inject(TOKEN) private readonly dep: unknown) { + super(dep); + } +} +``` + +### Safe for this rule: only used after `super()` + +**Not** reported by `forbid-nested-super-injections`. `prefer-inject-function` may migrate `y` to a field. + +```ts +import { Component } from '@angular/core'; + +class Base {} +class Y { + x(): void {} +} + +@Component({ template: '' }) +export class X extends Base { + constructor(private readonly y: Y) { + super(); + this.y.x(); + } +} +``` + +### Deferred read: parameter only referenced inside a non-invoked arrow + +The read is **not** treated as running before `super()` — **not** reported. + +```ts +import { Component } from '@angular/core'; + +class Base {} +class D {} + +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + const fn = () => d; + super(); + void fn; + } +} +``` + +### IIFE before `super()` — conservatively unsafe + +**Reported** — immediately invoked functions are treated as eager. + +```ts +import { Component } from '@angular/core'; + +class Base {} +class D {} + +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + (() => d)(); + super(); + } +} +``` + +### `function` declaration closure — stricter than arrows + +**Reported** today: only `ArrowFunctionExpression` / `FunctionExpression` get the “deferred read” shortcut for non-invoked nested functions, **not** `FunctionDeclaration`. + +```ts +import { Component } from '@angular/core'; + +class Base {} +class D {} + +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + function inner() { + return d; + } + void inner; + super(); + } +} +``` + +### Limitation: no call graph + +**Not** reported — at runtime `f()` could run before `super()`, but the heuristic does not track that dataflow. + +```ts +import { Component } from '@angular/core'; + +class Base {} +class D {} + +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + const f = () => d; + f(); + super(); + } +} +``` + +### Same constructor: warning + fixable sibling + +`d` stays **warn** (unsafe). `y` can be **`prefer-inject-function`** with fix (see that rule’s partial migration example). + +```ts +import { Component } from '@angular/core'; + +class Base {} +class D {} +class Y { + use(): void {} +} + +@Component({ template: '' }) +export class X extends Base { + constructor( + private d: D, + private readonly y: Y, + ) { + super(d); + this.y.use(); + } +} +``` + +## When a parameter is “unsafe” + +The implementation combines the AST, **scope** references, and a **parent-chain** heuristic: + +- Uses inside **`super(...)`** arguments → unsafe. +- Uses in the constructor body **before** the closing `)` of the first `super()` call → unsafe. +- Uses that appear **only** inside nested function bodies (e.g. `() => dep`) without invocation → **not** unsafe (deferred). +- **Immediately invoked** function expressions → unsafe (conservative). +- **`function` declaration** that closes over the parameter (not invoked) → **unsafe** (asymmetry vs arrows; see [example](#function-declaration-closure--stricter-than-arrows)). +- **No call graph**: invoked closures before `super()` may be missed (see [limitation example](#limitation-no-call-graph)). + +Base classes **without** `extends` do not get this “before `super`” analysis. + +## Messages + +- **`forbidNestedSuperInjections`** — includes `{{name}}` and explains the `super()` / field-init ordering issue. + +## Requirements + +Lint **TypeScript** with **`@typescript-eslint/parser`**. + +--- + +## Config examples + +### Flat config (opt-in) + +Spread `recommended` for **`member-ordering`**, then add this rule (and usually `prefer-inject-function`) yourself: + +```javascript +rules: { + ...angularClassOrdering.configs.recommended.rules, + 'angular-class-ordering/prefer-inject-function': 'error', + 'angular-class-ordering/forbid-nested-super-injections': 'warn', +}, +``` + +### Explicit (this rule only) + +```javascript +rules: { + 'angular-class-ordering/forbid-nested-super-injections': [ + 'warn', + { decorators: ['Component', 'Directive', 'Injectable', 'Pipe'] }, + ], +}, +``` diff --git a/docs/rules/member-ordering.md b/docs/rules/member-ordering.md index 56de8d8..30b3f5d 100644 --- a/docs/rules/member-ordering.md +++ b/docs/rules/member-ordering.md @@ -2,12 +2,24 @@ Enforces a consistent, Angular-aware order for class members in classes decorated with Angular decorators (by default: `Component`, `Directive`, `Injectable`, `Pipe`). -The rule classifies members using: +The plugin’s **`recommended`** preset turns **only** this rule on (at `error`). [`prefer-inject-function`](./prefer-inject-function.md) and [`forbid-nested-super-injections`](./forbid-nested-super-injections.md) are separate, opt-in rules in the same package. + +**Quick navigation:** [Custom `order` replaces defaults](#recipe-custom-order-replaces-defaults) · [`unknownPlacement`](#recipe-unknownplacement) · [Regex overlay slot](#recipe-regex-overlay-slot) · [Full default-order example](#full-example-every-default-slot-before-and-after-fix) + +## What gets checked + +The rule classifies each member using: - `@angular/core` APIs resolved via imports (local aliases like `inject as inj` resolve by **imported** symbol name — `inj()` still counts as `inject`) - Common Angular decorators (`@Input`, `@ViewChild`, `@HostBinding`, `@HostListener`, …) - Dedicated slots for `get`/`set` accessors (`getter-setter`), abstract members (`abstract`), and visibility buckets (`public-instance-field`, `public-instance-method`, …) -- **Overlay** entries in `order`: regex (`regex:` shorthand or `{ type: "pattern" }`), plus optional `custom-func-*` / `custom-dec-*` matchers (see below) +- **Overlay** entries in `order`: regex (`regex:` shorthand or `{ type: "pattern" }`), plus optional `custom-func-*` / `custom-dec-*` matchers (see [Options](#order)) + +It reports members that are out of rank relative to your configured `order` (and optionally unmatched categories when `unknownPlacement` is `"error"`). + +## Interaction with `prefer-inject-function` + +[`prefer-inject-function`](./prefer-inject-function.md) moves constructor DI into `inject()` fields. With the **default** `order`, those fields belong in the **`inject`** tier immediately after the constructor. Run both rules together if you want a consistent end state: DI as fields, then ordered with the rest of the class. ## Rule details @@ -79,28 +91,154 @@ Getter/setter pairs share one category (`getter-setter`); the setter rank is pin Private `#field` identifiers still classify into `private-instance-field`. -## Lint messages (`wrongOrder`) +## Examples -Violations summarise **readable slot labels** and a **narrow slice** of `order` (a few neighbouring groups) rather than dumping the entire configuration. +### Recipe: custom `order` replaces defaults -## Limits & caveats +If you set `order`, it **replaces** the entire built-in [`DEFAULT_ORDER`](../../src/rules/member-ordering.ts) — nothing is merged in. If you **omit** a tier such as `inject`, members that still classify as `inject` (for example `private readonly svc = inject(Foo)`) become **unmatched** and follow [`unknownPlacement`](#unknownplacement) (by default they sort **last**). -- **Namespace Angular imports**: `import * as core … core.signal(...)` isn't matched by `@angular/core` symbol resolution unless extended later — prefer normal named imports when using this rule. -- **Huge classes / deeply nested overlays**: autofix reshuffles verbatim source chunks; rerun Prettier/formatters afterward if needed. +Options (fragment): -## Fixer behaviour +```javascript +{ + order: ['constructor', 'public-instance-field'], +} +``` -Moves members wholesale with leading comments intact; newline gaps follow pragmatic heuristics (constructor fences, accessor pairs, categories). +**Before (violates order — `inject` field before constructor):** -## Requirements +```ts +import { Component, inject } from '@angular/core'; -Lint **TypeScript** with **`@typescript-eslint/parser`** (decorators, types, signals). +class Foo {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + private readonly svc = inject(Foo); + + constructor() {} + + plain = 1; +} +``` + +**After `eslint --fix`:** constructor first, then `plain`; the `inject()` field is **unknown** relative to this short `order` and is sorted **last** (default `unknownPlacement: 'last'`). + +```ts +import { Component, inject } from '@angular/core'; + +class Foo {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + constructor() {} + + plain = 1; + + private readonly svc = inject(Foo); +} +``` + +### Recipe: `unknownPlacement` + +Members whose category is **not** listed in `order` are “unknown”. The option controls what happens to them: + +| Value | Behaviour | +| ---------- | ----------------------------------------------------------------------------------- | +| `"last"` | Sort them after all configured slots (default). | +| `"ignore"` | Do not lint ordering for those members only. | +| `"error"` | Report `unknownCategory`; if any unknown remains, **`wrongOrder` has no auto-fix**. | + +**`ignore`** — only `inject` is in `order`; `extra` is skipped for ordering (valid code): + +```ts +import { Component, inject } from '@angular/core'; + +class Foo {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + private readonly svc = inject(Foo); + + extra = 1; +} +``` + +```javascript +// options +{ unknownPlacement: 'ignore', order: ['inject'] } +``` + +**`error` + unresolved unknown blocks fixing other violations** — constructor is out of order relative to `inject`, and `extra` is unknown; auto-fix is disabled (`output: null` in tests): + +```ts +import { Component, inject } from '@angular/core'; + +class Foo {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + private readonly svc = inject(Foo); + + constructor() {} + + extra = 1; +} +``` + +```javascript +{ unknownPlacement: 'error', order: ['constructor', 'inject'] } +``` + +### Recipe: regex overlay slot + +A pattern overlay can pull specific fields **between** built-in tiers. Here `legacyTracked` is matched by `{ type: 'pattern', regex: 'legacyTracked' }` and must sit **after** `inject` but **before** other `public-instance-field` members. + +**Before:** + +```ts +import { Component, inject } from '@angular/core'; + +class Foo {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + private readonly svc = inject(Foo); + + plain = 1; + + legacyTracked = true; +} +``` + +**After `eslint --fix`:** + +```ts +import { Component, inject } from '@angular/core'; + +class Foo {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + private readonly svc = inject(Foo); + + legacyTracked = true; + + plain = 1; +} +``` + +```javascript +{ + order: ['inject', { type: 'pattern', regex: 'legacyTracked' }, 'public-instance-field'], +} +``` --- -## Full example: every default slot (before and after `--fix`) +## Full example: every default slot (before and after fix) -The first block is deliberately **shuffled** — it includes one member for each category in [`DEFAULT_ORDER`](../../src/rules/member-ordering.ts) (constructor through visibility methods, plus `getter-setter` and `abstract`), and a tiny NgRx-shaped `store`/`selectSignal`/`select` demo. The second block is the exact result of this rule’s autofix (spacing follows the built-in gap heuristics). +The first block is deliberately **shuffled** — it includes one member for each category in [`DEFAULT_ORDER`](../../src/rules/member-ordering.ts) (constructor through visibility methods, plus `getter-setter` and `abstract`), and a tiny NgRx-shaped `store`/`selectSignal`/`select` demo. The second block is the exact result of ESLint **`--fix`** (spacing follows the built-in gap heuristics). > **Note:** The `store` field is ordered after `selectSignal`/`select` fields here only so the example fits the default slot list; at runtime you would normally inject a store or declare `store` above those members. Treat this as an illustration of **lint ordering**, not Angular data-flow. @@ -346,3 +484,53 @@ export abstract class KitchenSink { private privInstM(): void {} } ``` + +## Lint messages (`wrongOrder`) + +Violations summarise **readable slot labels** and a **narrow slice** of `order` (a few neighbouring groups) rather than dumping the entire configuration. + +## Fixer behaviour + +Moves members wholesale with leading comments intact; newline gaps follow pragmatic heuristics (constructor fences, accessor pairs, categories). + +**JSDoc / comments:** Each member’s slice starts at the first **leading** comment ESLint attaches to that node (`getCommentsBefore`) and includes trailing line/block comments before the next member when they belong to the same chunk. Block comments such as `/** … */` therefore **travel with the member** when it is reordered (see tests). After `--fix`, run your formatter (e.g. Prettier) on large edits so spacing matches team style. + +**Residual risk:** Trivia that is not associated by the parser with any class member (unusual placement) may not move as you expect; the rule is conservative text-splicing, not a full pretty-printer. + +## Limits & caveats + +- **Namespace Angular imports**: `import * as core … core.signal(...)` isn't matched by `@angular/core` symbol resolution unless extended later — prefer normal named imports when using this rule. +- **Huge classes / deeply nested overlays**: autofix reshuffles verbatim source chunks; rerun Prettier/formatters afterward if needed. +- **`inject()` not from `@angular/core`**: a call named `inject` that is **not** that imported symbol (e.g. a local helper) is classified as an ordinary field, not the `inject` slot. +- **Nested `inject` calls**: a field whose initializer is not a direct `inject(...)` call (for example a **ternary** at the root) is not given the `inject` slot unless `exprContainsCall` reaches it; today a ternary with `inject` in both branches is treated like a normal instance field. + +## Requirements + +Lint **TypeScript** with **`@typescript-eslint/parser`** (decorators, types, signals). + +--- + +## Config examples + +### Flat config (`recommended` preset) + +`configs.recommended` includes **`member-ordering`** only. Pair it with the inject rules manually if you need them (see the [README](../../README.md)). + +```javascript +rules: { + ...angularClassOrdering.configs.recommended.rules, +}, +``` + +### Custom `order` with overlay + +```javascript +rules: { + 'angular-class-ordering/member-ordering': [ + 'error', + { + order: ['inject', { type: 'pattern', regex: 'legacyTracked' }, 'public-instance-field'], + }, + ], +}, +``` diff --git a/docs/rules/prefer-inject-function.md b/docs/rules/prefer-inject-function.md new file mode 100644 index 0000000..68f1924 --- /dev/null +++ b/docs/rules/prefer-inject-function.md @@ -0,0 +1,494 @@ +# `prefer-inject-function` + +Suggests using Angular’s **`inject()`** for dependency-injected constructor parameters on classes that carry Angular’s class-level decorators, and can rewrite them in one batch per constructor when auto-fix is allowed. + +The plugin’s **`recommended`** preset does **not** enable this rule (only [`member-ordering`](./member-ordering.md) is on by default). Turn it on in ESLint when you want constructor → `inject()` migrations; consider enabling [`forbid-nested-super-injections`](./forbid-nested-super-injections.md) alongside it for subclass `super()` safety. + +**Quick navigation:** [Examples](#examples) · [Decorator mapping](#decorator-mapping-to-inject) · [Fixer walkthrough](#fixer-walkthrough-with-examples) · [Config](#config-examples) + +## What gets flagged + +The rule reports: + +- **Parameter properties** — `constructor(private readonly store: Store)` (any `public` / `protected` / `private`, with or without `readonly`, optional `?` on the binding). +- **Parameters with `@Inject(...)` from `@angular/core`** — including `constructor(@Inject(TOKEN) foo: Foo)` with no access modifier (the fix emits a **`private readonly`** field). + +It does **not** flag ordinary constructor parameters that have **no** access modifier and **no** `@Inject` from `@angular/core`. + +Classes are only checked when they use one of the configured **class-level** decorators (default: `Component`, `Directive`, `Injectable`, `Pipe`). + +## Interaction with `forbid-nested-super-injections` + +If a parameter is **unsafe** to turn into a field `inject()` (used inside `super(...)` or in code that runs before `super()` in a subclass), this rule **does not report it**. That case is handled by [`forbid-nested-super-injections`](./forbid-nested-super-injections.md) so severities do not duplicate. + +Other parameters in the **same** constructor can still be migrated when they are safe (see [Partial migration in a subclass constructor](#partial-migration-in-a-subclass-constructor)). + +## Rule details + +- **Type**: suggestion +- **Fixable**: yes (`code`), unless `autofix: false` or the parameter cannot be auto-fixed (unsupported decorators / token). + +## Options + +Single options object. + +### `decorators` + +Class-level decorator names that enable this rule for a class. + +- Type: `string[]` +- Default: `["Component", "Directive", "Injectable", "Pipe"]` + +### `autofix` + +When **`false`**, the rule never supplies an ESLint fix (no rewrites on `--fix`). + +- Type: `boolean` +- Default: `true` + +ESLint does not pass the configured rule severity (`warn` vs `error`) into the rule implementation. If you use **`warn`** and want to avoid fixes, set **`autofix: false`** explicitly. + +## Examples + +### Partial migration in a subclass constructor + +`d` is forwarded into `super(...)` → left as a constructor parameter (see [`forbid-nested-super-injections`](./forbid-nested-super-injections.md)). `y` is only used after `super()` → migrated to a field. + +**Before:** + +```ts +import { Component } from '@angular/core'; + +class Base {} +class D {} +class Y { + use(): void {} +} + +@Component({ template: '' }) +export class X extends Base { + constructor( + private d: D, + private readonly y: Y, + ) { + super(d); + this.y.use(); + } +} +``` + +**After `eslint --fix`:** + +```ts +import { Component, inject } from '@angular/core'; + +class Base {} +class D {} +class Y { + use(): void {} +} + +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + super(d); + this.y.use(); + } + + private readonly y = inject(Y); +} +``` + +### Report only: union type (no auto-fix) + +Union and other non-simple type annotations are reported but not auto-fixed; migrate the token manually (often with `@Inject(...)`). + +```ts +import { Component } from '@angular/core'; + +class A {} +class B {} + +@Component({ template: '' }) +export class X { + // preferInject — fix manually (e.g. pick a concrete @Inject token) + constructor(private readonly x: A | B) {} +} +``` + +### Report only: unsupported decorator combination + +`@Inject` together with `@Attribute` on the same parameter is intentionally manual. + +```ts +import { Attribute, Component, Inject } from '@angular/core'; + +const TOKEN = {}; + +@Component({ template: '' }) +export class X { + constructor(@Inject(TOKEN) @Attribute('role') private readonly x: string) {} +} +``` + +## Decorator mapping to `inject(...)` + +Decorators are resolved via **`@angular/core` imports** (local names map to the imported symbol). + +| Decorator (from `@angular/core`) | Effect in fix | +| ---------------------------------------- | ------------------------------------------------------------------------------------ | +| `@Inject(token)` | First argument: source of `token` | +| `@Optional()` | `inject(..., { optional: true })` | +| `@Host()` | `host: true` | +| `@Self()` | `self: true` | +| `@SkipSelf()` | `skipSelf: true` | +| `@Attribute('name')` with string literal | `inject(new HostAttributeToken("name"))` and adds `HostAttributeToken` to the import | + +Flags merge into **one** options object when needed (stable key order: `host`, `optional`, `self`, `skipSelf`). + +If there is **no** `@Inject` and **no** `@Attribute`, the first argument comes from a **simple type reference** on the parameter (`Store`, `ns.Type`). Union types, inline object types, and other complex annotations are **not auto-fixable** (report only). + +**Unsupported** parameter decorators (anything outside the table above, `@Inject` without an argument, `@Attribute` without a string literal, or **`@Inject` combined with `@Attribute`**) still report **`preferInject`** with a hint to fix manually, **without** an auto-fix. + +### `@Optional()` + +**Before:** + +```ts +import { Component, Optional } from '@angular/core'; + +class Store {} + +@Component({ template: '' }) +export class X { + constructor(@Optional() private readonly store: Store) {} +} +``` + +**After `eslint --fix`:** + +```ts +import { Component, Optional, inject } from '@angular/core'; + +class Store {} + +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly store = inject(Store, { optional: true }); +} +``` + +### `@Host()` and `@Self()` + +**Before:** + +```ts +import { Component, Host, Self } from '@angular/core'; + +class Tok {} + +@Component({ template: '' }) +export class X { + constructor(@Host() @Self() private readonly t: Tok) {} +} +``` + +**After `eslint --fix`:** + +```ts +import { Component, Host, Self, inject } from '@angular/core'; + +class Tok {} + +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly t = inject(Tok, { host: true, self: true }); +} +``` + +### `@Attribute('name')` (no `@Inject`) + +**Before:** + +```ts +import { Attribute, Component } from '@angular/core'; + +@Component({ template: '' }) +export class X { + constructor(@Attribute('role') private readonly role: string | null) {} +} +``` + +**After `eslint --fix`:** + +```ts +import { Attribute, Component, HostAttributeToken, inject } from '@angular/core'; + +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly role = inject(new HostAttributeToken('role')); +} +``` + +## Fixer walkthrough (with examples) + +On **`eslint --fix`**, one **batch** fix per constructor migrates **all** fixable parameters in that constructor at once. + +### 1. `@angular/core` import + +`inject` is added to an existing `@angular/core` import when possible; otherwise a new import is introduced. Extra symbols (e.g. `HostAttributeToken`) are added when needed. + +**Before:** + +```ts +import { Component } from '@angular/core'; + +class Svc {} + +@Component({ template: '' }) +export class X { + constructor(private readonly svc: Svc) {} +} +``` + +**After `eslint --fix`:** + +```ts +import { Component, inject } from '@angular/core'; + +class Svc {} + +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly svc = inject(Svc); +} +``` + +### 2. Whole constructor parameter list + +The inner `( … )` of the constructor is replaced: migrated DI parameters disappear; non-DI parameters stay with correct commas and line breaks. + +**Before:** + +```ts +import { Component, Inject } from '@angular/core'; + +class A {} +class B {} +const TOK = {}; + +@Component({ template: '' }) +export class X { + constructor( + private readonly a: A, + private b: B, + @Inject(TOK) private readonly token: unknown, + ) {} +} +``` + +**After `eslint --fix`:** + +```ts +import { Component, Inject, inject } from '@angular/core'; + +class A {} +class B {} +const TOK = {}; + +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly a = inject(A); + private b = inject(B); + private readonly token = inject(TOK); +} +``` + +### 3. Constructor body: `this.` and object shorthand + +Reads of migrated parameters become **`this.`**. **Object literal shorthand** is expanded so the name still refers to the field. + +**Before:** + +```ts +import { Component } from '@angular/core'; + +class Item { + id = 1; +} + +@Component({ template: '' }) +export class X { + constructor(private readonly item: Item) { + const config = { item }; + void config; + } +} +``` + +**After `eslint --fix`:** + +```ts +import { Component, inject } from '@angular/core'; + +class Item { + id = 1; +} + +@Component({ template: '' }) +export class X { + constructor() { + const config = { item: this.item }; + void config; + } + + private readonly item = inject(Item); +} +``` + +(Plain reads use `this.item` the same way as in the [partial migration](#partial-migration-in-a-subclass-constructor) example.) + +### 4. Where new fields are inserted + +New fields are inserted **immediately after** the constructor: **one** blank line after the constructor’s closing `}`; multiple new fields are **adjacent** (no extra blank lines between them). + +**Before:** + +```ts +import { Component } from '@angular/core'; + +class A {} +class B {} + +@Component({ template: '' }) +export class X { + constructor( + private readonly a: A, + private readonly b: B, + ) {} + + regularMethod(): void {} +} +``` + +**After `eslint --fix`:** + +```ts +import { Component, inject } from '@angular/core'; + +class A {} +class B {} + +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly a = inject(A); + private readonly b = inject(B); + + regularMethod(): void {} +} +``` + +### 5. Access modifiers + +Parameter property modifiers are preserved on the new field. A bare `@Inject(...)` parameter without an access modifier becomes **`private readonly`**. + +**Before:** + +```ts +import { Component } from '@angular/core'; + +class Pub {} +class Prot {} +class Priv {} + +@Component({ template: '' }) +export class X { + constructor( + public readonly pub: Pub, + protected prot: Prot, + private readonly priv: Priv, + ) {} +} +``` + +**After `eslint --fix`:** + +```ts +import { Component, inject } from '@angular/core'; + +class Pub {} +class Prot {} +class Priv {} + +@Component({ template: '' }) +export class X { + constructor() {} + + public readonly pub = inject(Pub); + protected prot = inject(Prot); + private readonly priv = inject(Priv); +} +``` + +## Notes for ESLint `--fix` + +The fix is attached only to the **first** fixable diagnostic for that constructor so ESLint does not apply overlapping text replacements multiple times. After a successful fix, run lint again to clear any remaining messages for that file. + +## Messages + +- **`preferInject`** — includes `{{name}}` and optional `{{details}}` when the parameter cannot be auto-fixed. + +## Limits & caveats + +- **Scope / parser**: relies on `@typescript-eslint/parser` and ESLint’s **scope manager** for references and `super()` safety classification. +- **Imports**: does not aggressively remove unused symbols such as `Inject` after a fix; follow up with your formatter or unused-import rules if needed. +- **IIFE** parameters used before `super()` are treated conservatively as unsafe in the companion rule; nested-function cases are approximated. +- **Parameter property + `const`/`let` shadowing**: the fixer skips rewrites when a **`const` or `let`** in an enclosing block with the same name appears **before** the reference. **`this.`** reads are not rewritten. +- **Defaults**: migrating removes constructor-parameter **default initializers**; the new field is plain `inject(...)` (no default). Adjust manually if you relied on a default. +- **Comments inside the parameter list** are not preserved (the whole inner `(`…`)` span is replaced). Put comments above the constructor or on the new fields after fixing. +- **Parentheses in defaults**: the parameter-list range is found by counting `(` / `)` in the constructor slice — defaults containing `(` inside **strings** or **regex literals** could theoretically confuse it; treat unusual cases with care or fix manually. + +## Requirements + +Lint **TypeScript** with **`@typescript-eslint/parser`** (decorators and parameter properties). + +--- + +## Config examples + +### Flat config + +```javascript +rules: { + 'angular-class-ordering/prefer-inject-function': [ + 'error', + { + decorators: ['Component', 'Injectable'], + autofix: true, + }, + ], +}, +``` + +### Warn without fixes + +```javascript +rules: { + 'angular-class-ordering/prefer-inject-function': [ + 'warn', + { autofix: false }, + ], +}, +``` diff --git a/lib/index.d.ts b/lib/index.d.ts index dd282b9..9b88305 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1,6 +1,6 @@ import { ESLint } from 'eslint'; /** - * ESLint plugin exposing Angular-aware class member ordering. + * ESLint plugin exposing Angular-aware class member ordering and inject-related rules. */ declare const plugin: ESLint.Plugin; export = plugin; diff --git a/lib/index.d.ts.map b/lib/index.d.ts.map index 0c7c1ce..697391f 100644 --- a/lib/index.d.ts.map +++ b/lib/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAIhC;;GAEG;AACH,QAAA,MAAM,MAAM,EAQI,MAAM,CAAC,MAAM,CAAC;AAE9B,SAAS,MAAM,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAYhC;;GAEG;AACH,QAAA,MAAM,MAAM,EAiBI,MAAM,CAAC,MAAM,CAAC;AAE9B,SAAS,MAAM,CAAC"} \ No newline at end of file diff --git a/lib/index.js b/lib/index.js index 5865c97..bdaa7ae 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,17 +3,33 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; const package_json_1 = __importDefault(require("../package.json")); +const forbid_nested_super_injections_1 = require("./rules/forbid-nested-super-injections"); const member_ordering_1 = require("./rules/member-ordering"); +const prefer_inject_function_1 = require("./rules/prefer-inject-function"); +const pluginRules = { + [member_ordering_1.RULE_NAME]: member_ordering_1.rule, + [prefer_inject_function_1.RULE_NAME]: prefer_inject_function_1.rule, + [forbid_nested_super_injections_1.RULE_NAME]: forbid_nested_super_injections_1.rule, +}; /** - * ESLint plugin exposing Angular-aware class member ordering. + * ESLint plugin exposing Angular-aware class member ordering and inject-related rules. */ const plugin = { meta: { name: package_json_1.default.name, version: package_json_1.default.version, }, - rules: { - [member_ordering_1.RULE_NAME]: member_ordering_1.rule, + rules: pluginRules, + configs: { + /** + * Safe default: class member layout only. `prefer-inject-function` and + * `forbid-nested-super-injections` are opt-in (they can rewrite or steer DI refactors). + */ + recommended: { + rules: { + [`${package_json_1.default.name.replace(/^eslint-plugin-/, '')}/${member_ordering_1.RULE_NAME}`]: 'error', + }, + }, }, }; module.exports = plugin; diff --git a/lib/index.js.map b/lib/index.js.map index 27243b3..0b94bca 100644 --- a/lib/index.js.map +++ b/lib/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;AACA,mEAAkC;AAClC,6DAA0D;AAE1D;;GAEG;AACH,MAAM,MAAM,GAAG;IACX,IAAI,EAAE;QACF,IAAI,EAAE,sBAAG,CAAC,IAAI;QACd,OAAO,EAAE,sBAAG,CAAC,OAAO;KACvB;IACD,KAAK,EAAE;QACH,CAAC,2BAAS,CAAC,EAAE,sBAAI;KACpB;CACwB,CAAC;AAE9B,iBAAS,MAAM,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;AACA,mEAAkC;AAClC,2FAAwH;AACxH,6DAA6G;AAC7G,2EAAgH;AAEhH,MAAM,WAAW,GAAG;IAChB,CAAC,2BAAyB,CAAC,EAAE,sBAAkB;IAC/C,CAAC,kCAAuB,CAAC,EAAE,6BAAgB;IAC3C,CAAC,0CAAuB,CAAC,EAAE,qCAAgB;CACrC,CAAC;AAEX;;GAEG;AACH,MAAM,MAAM,GAAG;IACX,IAAI,EAAE;QACF,IAAI,EAAE,sBAAG,CAAC,IAAI;QACd,OAAO,EAAE,sBAAG,CAAC,OAAO;KACvB;IACD,KAAK,EAAE,WAAW;IAClB,OAAO,EAAE;QACL;;;WAGG;QACH,WAAW,EAAE;YACT,KAAK,EAAE;gBACH,CAAC,GAAG,sBAAG,CAAC,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE,EAAE,CAAC,IAAI,2BAAyB,EAAE,CAAC,EAAE,OAAO;aACvF;SACJ;KACJ;CACwB,CAAC;AAE9B,iBAAS,MAAM,CAAC"} \ No newline at end of file diff --git a/lib/rules/forbid-nested-super-injections.d.ts b/lib/rules/forbid-nested-super-injections.d.ts new file mode 100644 index 0000000..822d604 --- /dev/null +++ b/lib/rules/forbid-nested-super-injections.d.ts @@ -0,0 +1,15 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; +export declare const RULE_NAME = "forbid-nested-super-injections"; +export type ForbidNestedMessageIds = 'forbidNestedSuperInjections'; +export declare const forbidNestedMessages: { + forbidNestedSuperInjections: string; +}; +type RuleOptions = { + decorators?: string[]; +}; +type OptionsTuple = [RuleOptions?]; +export declare const rule: ESLintUtils.RuleModule<"forbidNestedSuperInjections", OptionsTuple, unknown, ESLintUtils.RuleListener> & { + name: string; +}; +export {}; +//# sourceMappingURL=forbid-nested-super-injections.d.ts.map \ No newline at end of file diff --git a/lib/rules/forbid-nested-super-injections.d.ts.map b/lib/rules/forbid-nested-super-injections.d.ts.map new file mode 100644 index 0000000..48ab225 --- /dev/null +++ b/lib/rules/forbid-nested-super-injections.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"forbid-nested-super-injections.d.ts","sourceRoot":"","sources":["../../src/rules/forbid-nested-super-injections.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAWvD,eAAO,MAAM,SAAS,mCAAmC,CAAC;AAM1D,MAAM,MAAM,sBAAsB,GAAG,6BAA6B,CAAC;AAEnE,eAAO,MAAM,oBAAoB;;CAGiB,CAAC;AAEnD,KAAK,WAAW,GAAG;IACf,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB,CAAC;AAEF,KAAK,YAAY,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC;AAEnC,eAAO,MAAM,IAAI;;CAqDf,CAAC"} \ No newline at end of file diff --git a/lib/rules/forbid-nested-super-injections.js b/lib/rules/forbid-nested-super-injections.js new file mode 100644 index 0000000..3ab1ff1 --- /dev/null +++ b/lib/rules/forbid-nested-super-injections.js @@ -0,0 +1,65 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.rule = exports.forbidNestedMessages = exports.RULE_NAME = void 0; +const utils_1 = require("@typescript-eslint/utils"); +const injection_context_utils_1 = require("./injection-context.utils"); +exports.RULE_NAME = 'forbid-nested-super-injections'; +const createRule = utils_1.ESLintUtils.RuleCreator((name) => `https://github.com/Leritas/eslint-plugin-angular-class-ordering/blob/main/docs/rules/${name}.md`); +exports.forbidNestedMessages = { + forbidNestedSuperInjections: 'Constructor dependency `{{name}}` is used before subclass `inject()` fields exist (e.g. in `super(...)` or earlier code). Refactor the base class to stop passing this through `super`, for example by using `inject()` on the parent; then `prefer-inject-function` can move it safely.', +}; +exports.rule = createRule({ + name: exports.RULE_NAME, + meta: { + type: 'problem', + docs: { + description: 'Flags constructor DI parameters that cannot be migrated to `inject()` because they are used before `super()` completes.', + }, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + decorators: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + ], + messages: exports.forbidNestedMessages, + }, + defaultOptions: [ + { + decorators: [...injection_context_utils_1.DEFAULT_INJECT_RULE_DECORATORS], + }, + ], + create(context, [options = {}]) { + const decorators = options.decorators ?? [...injection_context_utils_1.DEFAULT_INJECT_RULE_DECORATORS]; + const sourceCode = context.sourceCode; + const program = sourceCode.ast; + const importMap = (0, injection_context_utils_1.buildImportMap)(program); + return { + Program() { + for (const classNode of (0, injection_context_utils_1.iterateClasses)(program)) { + if (!(0, injection_context_utils_1.classMatchesDecorators)(classNode, decorators)) + continue; + const ctor = (0, injection_context_utils_1.findConstructor)(classNode.body); + if (!ctor) + continue; + const analyses = (0, injection_context_utils_1.analyzeDiParams)(classNode, ctor, importMap, sourceCode); + for (const a of analyses) { + if (!a.unsafeForSuperField) + continue; + context.report({ + node: a.paramNode, + messageId: 'forbidNestedSuperInjections', + data: { name: a.name }, + }); + } + } + }, + }; + }, +}); +//# sourceMappingURL=forbid-nested-super-injections.js.map \ No newline at end of file diff --git a/lib/rules/forbid-nested-super-injections.js.map b/lib/rules/forbid-nested-super-injections.js.map new file mode 100644 index 0000000..e5cc640 --- /dev/null +++ b/lib/rules/forbid-nested-super-injections.js.map @@ -0,0 +1 @@ +{"version":3,"file":"forbid-nested-super-injections.js","sourceRoot":"","sources":["../../src/rules/forbid-nested-super-injections.ts"],"names":[],"mappings":";;;AAAA,oDAAuD;AAEvD,uEAOmC;AAEtB,QAAA,SAAS,GAAG,gCAAgC,CAAC;AAE1D,MAAM,UAAU,GAAG,mBAAW,CAAC,WAAW,CACtC,CAAC,IAAI,EAAE,EAAE,CAAC,wFAAwF,IAAI,KAAK,CAC9G,CAAC;AAIW,QAAA,oBAAoB,GAAG;IAChC,2BAA2B,EACvB,0RAA0R;CAChP,CAAC;AAQtC,QAAA,IAAI,GAAG,UAAU,CAAuC;IACjE,IAAI,EAAE,iBAAS;IACf,IAAI,EAAE;QACF,IAAI,EAAE,SAAS;QACf,IAAI,EAAE;YACF,WAAW,EACP,yHAAyH;SAChI;QACD,MAAM,EAAE;YACJ;gBACI,IAAI,EAAE,QAAQ;gBACd,oBAAoB,EAAE,KAAK;gBAC3B,UAAU,EAAE;oBACR,UAAU,EAAE;wBACR,IAAI,EAAE,OAAO;wBACb,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;qBAC5B;iBACJ;aACJ;SACJ;QACD,QAAQ,EAAE,4BAAoB;KACjC;IACD,cAAc,EAAE;QACZ;YACI,UAAU,EAAE,CAAC,GAAG,wDAA8B,CAAC;SAClD;KACJ;IACD,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,GAAG,EAAE,CAAC;QAC1B,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,CAAC,GAAG,wDAA8B,CAAC,CAAC;QAC7E,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;QACtC,MAAM,OAAO,GAAG,UAAU,CAAC,GAA0D,CAAC;QACtF,MAAM,SAAS,GAAG,IAAA,wCAAc,EAAC,OAAO,CAAC,CAAC;QAE1C,OAAO;YACH,OAAO;gBACH,KAAK,MAAM,SAAS,IAAI,IAAA,wCAAc,EAAC,OAAO,CAAC,EAAE,CAAC;oBAC9C,IAAI,CAAC,IAAA,gDAAsB,EAAC,SAAS,EAAE,UAAU,CAAC;wBAAE,SAAS;oBAC7D,MAAM,IAAI,GAAG,IAAA,yCAAe,EAAC,SAAS,CAAC,IAAI,CAAC,CAAC;oBAC7C,IAAI,CAAC,IAAI;wBAAE,SAAS;oBAEpB,MAAM,QAAQ,GAAG,IAAA,yCAAe,EAAC,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC;oBACzE,KAAK,MAAM,CAAC,IAAI,QAAQ,EAAE,CAAC;wBACvB,IAAI,CAAC,CAAC,CAAC,mBAAmB;4BAAE,SAAS;wBACrC,OAAO,CAAC,MAAM,CAAC;4BACX,IAAI,EAAE,CAAC,CAAC,SAAS;4BACjB,SAAS,EAAE,6BAA6B;4BACxC,IAAI,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE;yBACzB,CAAC,CAAC;oBACP,CAAC;gBACL,CAAC;YACL,CAAC;SACJ,CAAC;IACN,CAAC;CACJ,CAAC,CAAC"} \ No newline at end of file diff --git a/lib/rules/injection-context.utils.d.ts b/lib/rules/injection-context.utils.d.ts new file mode 100644 index 0000000..f6ae110 --- /dev/null +++ b/lib/rules/injection-context.utils.d.ts @@ -0,0 +1,46 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +export declare const ANGULAR_CORE = "@angular/core"; +export declare const DEFAULT_INJECT_RULE_DECORATORS: readonly ["Component", "Directive", "Injectable", "Pipe"]; +export type ImportBinding = { + module: string; + importedName: string; +}; +export declare function buildImportMap(programNode: TSESTree.Program): Map; +export declare function iterateClasses(programNode: TSESTree.Program): Generator; +export declare function getDecoratorNames(decorators: TSESTree.Decorator[] | undefined): string[]; +export declare function classMatchesDecorators(classNode: TSESTree.ClassDeclaration, decoratorNames: readonly string[]): boolean; +export declare function getDecoratorImportedName(dec: TSESTree.Decorator, importMap: Map): string | null; +/** Build parent pointers for subtree (no AST mutation). */ +export declare function buildParentMap(root: TSESTree.Node): Map; +export declare function findConstructor(classBody: TSESTree.ClassBody): (TSESTree.MethodDefinition & { + kind: 'constructor'; +}) | null; +export declare function findFirstSuperCall(body: TSESTree.BlockStatement): TSESTree.CallExpression | null; +/** + * Whether a reference to a constructor parameter must stay in the constructor + * (cannot become a field initializer) due to `super()` ordering. + */ +export declare function isParamRefUnsafeForSuperField(refIdentifier: TSESTree.Identifier, firstSuper: TSESTree.CallExpression, parentMap: Map): boolean; +export type InjectOptionFlags = { + optional?: boolean; + host?: boolean; + self?: boolean; + skipSelf?: boolean; +}; +export type DiParamAnalysis = { + paramNode: TSESTree.TSParameterProperty | TSESTree.Identifier; + name: string; + injectFirstArg: string | null; + injectOptions: InjectOptionFlags; + usesAttributeDecorator: boolean; + modifiers: string; + unsupportedDecorator: boolean; + unsafeForSuperField: boolean; +}; +export declare function isDiConstructorParam(node: TSESTree.Node, importMap: Map): node is TSESTree.TSParameterProperty | TSESTree.Identifier; +export declare function getBindingFromParam(p: TSESTree.TSParameterProperty | TSESTree.Identifier): TSESTree.Identifier | null; +export declare function analyzeDiParams(classNode: TSESTree.ClassDeclaration, ctor: TSESTree.MethodDefinition & { + kind: 'constructor'; +}, importMap: Map, sourceCode: TSESLint.SourceCode): DiParamAnalysis[]; +export declare function formatInjectOptions(flags: InjectOptionFlags): string | null; +//# sourceMappingURL=injection-context.utils.d.ts.map \ No newline at end of file diff --git a/lib/rules/injection-context.utils.d.ts.map b/lib/rules/injection-context.utils.d.ts.map new file mode 100644 index 0000000..963d652 --- /dev/null +++ b/lib/rules/injection-context.utils.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"injection-context.utils.d.ts","sourceRoot":"","sources":["../../src/rules/injection-context.utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAGnE,eAAO,MAAM,YAAY,kBAAkB,CAAC;AAE5C,eAAO,MAAM,8BAA8B,2DAA4D,CAAC;AAExG,MAAM,MAAM,aAAa,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AAErE,wBAAgB,cAAc,CAAC,WAAW,EAAE,QAAQ,CAAC,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAqBxF;AAED,wBAAiB,cAAc,CAAC,WAAW,EAAE,QAAQ,CAAC,OAAO,GAAG,SAAS,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAUnG;AAED,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,QAAQ,CAAC,SAAS,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,CAkBxF;AAED,wBAAgB,sBAAsB,CAClC,SAAS,EAAE,QAAQ,CAAC,gBAAgB,EACpC,cAAc,EAAE,SAAS,MAAM,EAAE,GAClC,OAAO,CAGT;AAED,wBAAgB,wBAAwB,CACpC,GAAG,EAAE,QAAQ,CAAC,SAAS,EACvB,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,GACtC,MAAM,GAAG,IAAI,CAcf;AAmBD,2DAA2D;AAC3D,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,GAAG,IAAI,CAAC,CAQ5F;AAED,wBAAgB,eAAe,CAC3B,SAAS,EAAE,QAAQ,CAAC,SAAS,GAC9B,CAAC,QAAQ,CAAC,gBAAgB,GAAG;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,CAAC,GAAG,IAAI,CAY9D;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,QAAQ,CAAC,cAAc,GAAG,QAAQ,CAAC,cAAc,GAAG,IAAI,CAUhG;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,CACzC,aAAa,EAAE,QAAQ,CAAC,UAAU,EAClC,UAAU,EAAE,QAAQ,CAAC,cAAc,EACnC,SAAS,EAAE,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,GAAG,IAAI,CAAC,GACpD,OAAO,CAoBT;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC1B,SAAS,EAAE,QAAQ,CAAC,mBAAmB,GAAG,QAAQ,CAAC,UAAU,CAAC;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,aAAa,EAAE,iBAAiB,CAAC;IACjC,sBAAsB,EAAE,OAAO,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,mBAAmB,EAAE,OAAO,CAAC;CAChC,CAAC;AA+DF,wBAAgB,oBAAoB,CAChC,IAAI,EAAE,QAAQ,CAAC,IAAI,EACnB,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,GACtC,IAAI,IAAI,QAAQ,CAAC,mBAAmB,GAAG,QAAQ,CAAC,UAAU,CAQ5D;AAED,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,QAAQ,CAAC,mBAAmB,GAAG,QAAQ,CAAC,UAAU,GAAG,QAAQ,CAAC,UAAU,GAAG,IAAI,CAQrH;AAqDD,wBAAgB,eAAe,CAC3B,SAAS,EAAE,QAAQ,CAAC,gBAAgB,EACpC,IAAI,EAAE,QAAQ,CAAC,gBAAgB,GAAG;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,EACzD,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,EACrC,UAAU,EAAE,QAAQ,CAAC,UAAU,GAChC,eAAe,EAAE,CA8EnB;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,CAS3E"} \ No newline at end of file diff --git a/lib/rules/injection-context.utils.js b/lib/rules/injection-context.utils.js new file mode 100644 index 0000000..2666d10 --- /dev/null +++ b/lib/rules/injection-context.utils.js @@ -0,0 +1,371 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_INJECT_RULE_DECORATORS = exports.ANGULAR_CORE = void 0; +exports.buildImportMap = buildImportMap; +exports.iterateClasses = iterateClasses; +exports.getDecoratorNames = getDecoratorNames; +exports.classMatchesDecorators = classMatchesDecorators; +exports.getDecoratorImportedName = getDecoratorImportedName; +exports.buildParentMap = buildParentMap; +exports.findConstructor = findConstructor; +exports.findFirstSuperCall = findFirstSuperCall; +exports.isParamRefUnsafeForSuperField = isParamRefUnsafeForSuperField; +exports.isDiConstructorParam = isDiConstructorParam; +exports.getBindingFromParam = getBindingFromParam; +exports.analyzeDiParams = analyzeDiParams; +exports.formatInjectOptions = formatInjectOptions; +const visitor_keys_1 = require("@typescript-eslint/visitor-keys"); +exports.ANGULAR_CORE = '@angular/core'; +exports.DEFAULT_INJECT_RULE_DECORATORS = ['Component', 'Directive', 'Injectable', 'Pipe']; +function buildImportMap(programNode) { + const map = new Map(); + for (const stmt of programNode.body) { + if (stmt.type !== 'ImportDeclaration' || typeof stmt.source.value !== 'string') + continue; + const moduleName = stmt.source.value; + for (const spec of stmt.specifiers ?? []) { + let importedName; + if (spec.type === 'ImportDefaultSpecifier') + importedName = 'default'; + else if (spec.type === 'ImportSpecifier') { + const imp = spec.imported; + importedName = + imp.type === 'Identifier' + ? imp.name + : imp.type === 'Literal' && typeof imp.value === 'string' + ? imp.value + : undefined; + } + if (importedName) + map.set(spec.local.name, { module: moduleName, importedName }); + } + } + return map; +} +function* iterateClasses(programNode) { + for (const stmt of programNode.body) { + if (stmt.type === 'ClassDeclaration') + yield stmt; + if ((stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportDefaultDeclaration') && + stmt.declaration?.type === 'ClassDeclaration') { + yield stmt.declaration; + } + } +} +function getDecoratorNames(decorators) { + return (decorators ?? []) + .map((d) => { + const expr = d.expression; + if (expr.type === 'CallExpression') { + if (expr.callee.type === 'Identifier') + return expr.callee.name; + if (expr.callee.type === 'MemberExpression' && + !expr.callee.computed && + expr.callee.property.type === 'Identifier') { + return expr.callee.property.name; + } + return undefined; + } + return expr.type === 'Identifier' ? expr.name : undefined; + }) + .filter((n) => Boolean(n)); +} +function classMatchesDecorators(classNode, decoratorNames) { + const classDecorators = getDecoratorNames(classNode.decorators); + return classDecorators.some((n) => decoratorNames.includes(n)); +} +function getDecoratorImportedName(dec, importMap) { + const expr = dec.expression; + if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier') { + const loc = expr.callee.name; + const b = importMap.get(loc); + if (b?.module === exports.ANGULAR_CORE) + return b.importedName; + return expr.callee.name; + } + if (expr.type === 'Identifier') { + const b = importMap.get(expr.name); + if (b?.module === exports.ANGULAR_CORE) + return b.importedName; + return expr.name; + } + return null; +} +function traverseChildren(node, visit) { + const keys = visitor_keys_1.visitorKeys[node.type] ?? []; + for (const key of keys) { + const v = node[key]; + if (v === null || v === undefined) + continue; + if (Array.isArray(v)) { + for (const item of v) { + if (item && typeof item === 'object' && item !== null && 'type' in item) { + visit(item); + } + } + } + else if (typeof v === 'object' && v !== null && 'type' in v) { + visit(v); + } + } +} +/** Build parent pointers for subtree (no AST mutation). */ +function buildParentMap(root) { + const parents = new Map(); + const visit = (node, parent) => { + parents.set(node, parent); + traverseChildren(node, (child) => visit(child, node)); + }; + visit(root, null); + return parents; +} +function findConstructor(classBody) { + for (const el of classBody.body) { + if (el.type === 'MethodDefinition' && + el.kind === 'constructor' && + el.static === false && + el.value.type === 'FunctionExpression') { + return el; + } + } + return null; +} +function findFirstSuperCall(body) { + let best = null; + const walk = (n) => { + if (n.type === 'CallExpression' && n.callee.type === 'Super') { + if (!best || n.range[0] < best.range[0]) + best = n; + } + traverseChildren(n, walk); + }; + walk(body); + return best; +} +/** + * Whether a reference to a constructor parameter must stay in the constructor + * (cannot become a field initializer) due to `super()` ordering. + */ +function isParamRefUnsafeForSuperField(refIdentifier, firstSuper, parentMap) { + if (refIdentifier.range[0] >= firstSuper.range[1]) + return false; + let n = refIdentifier; + while (n) { + const parentNode = parentMap.get(n) ?? null; + if (!parentNode) + break; + if (parentNode.type === 'CallExpression' && parentNode.callee.type === 'Super') { + return true; + } + if (parentNode.type === 'ArrowFunctionExpression' || parentNode.type === 'FunctionExpression') { + const gp = parentMap.get(parentNode) ?? null; + if (gp?.type === 'CallExpression' && gp.callee === parentNode) { + return true; + } + return false; + } + n = parentNode; + } + return refIdentifier.range[0] < firstSuper.range[1]; +} +const ALLOWED_PARAM_DECORATORS = new Set(['Inject', 'Optional', 'Host', 'Self', 'SkipSelf', 'Attribute']); +function collectParamDecoratorFlags(decorators, importMap) { + let unsupported = false; + const flags = {}; + let attributeStringArg = null; + let hasInject = false; + for (const d of decorators ?? []) { + const imported = getDecoratorImportedName(d, importMap); + const expr = d.expression; + if (imported === 'Inject') { + hasInject = true; + if (expr.type !== 'CallExpression' || !expr.arguments[0]) + unsupported = true; + continue; + } + if (!imported || !ALLOWED_PARAM_DECORATORS.has(imported)) { + unsupported = true; + continue; + } + switch (imported) { + case 'Optional': + flags.optional = true; + break; + case 'Host': + flags.host = true; + break; + case 'Self': + flags.self = true; + break; + case 'SkipSelf': + flags.skipSelf = true; + break; + case 'Attribute': + if (expr.type === 'CallExpression' && expr.arguments[0]?.type === 'Literal') { + const lit = expr.arguments[0]; + if (typeof lit.value === 'string') + attributeStringArg = lit.value; + else + unsupported = true; + } + else { + unsupported = true; + } + break; + default: + break; + } + } + return { unsupported, flags, attributeStringArg, hasInject }; +} +function isDiConstructorParam(node, importMap) { + if (node.type === 'TSParameterProperty') + return true; + if (node.type === 'Identifier') { + const decs = node.decorators; + if (!decs?.length) + return false; + return decs.some((d) => getDecoratorImportedName(d, importMap) === 'Inject'); + } + return false; +} +function getBindingFromParam(p) { + if (p.type === 'TSParameterProperty') { + const inner = p.parameter; + if (inner.type === 'Identifier') + return inner; + if (inner.type === 'AssignmentPattern' && inner.left.type === 'Identifier') + return inner.left; + return null; + } + return p; +} +function paramName(p) { + const id = getBindingFromParam(p); + return id?.name ?? null; +} +function formatModifiers(p) { + const parts = []; + if (p.accessibility) + parts.push(p.accessibility); + if (p.readonly) + parts.push('readonly'); + return parts.join(' '); +} +function typeRefToInjectArg(typeAnn, sourceCode) { + if (!typeAnn?.typeAnnotation) + return null; + const t = typeAnn.typeAnnotation; + if (t.type === 'TSTypeReference' && t.typeName.type === 'Identifier') { + return sourceCode.getText(t.typeName); + } + if (t.type === 'TSTypeReference' && t.typeName.type === 'TSQualifiedName') { + return sourceCode.getText(t.typeName); + } + return null; +} +function injectTokenFromInjectDecorator(decorators, importMap, sourceCode) { + for (const d of decorators ?? []) { + if (getDecoratorImportedName(d, importMap) !== 'Inject') + continue; + const expr = d.expression; + if (expr.type !== 'CallExpression' || !expr.arguments[0]) + return null; + return sourceCode.getText(expr.arguments[0]); + } + return null; +} +function allDecoratorsOnParam(p) { + if (p.type === 'TSParameterProperty') + return p.decorators ?? []; + return p.decorators ?? []; +} +function refIsWithinParamBinding(id, binding) { + if (!binding?.range || !id.range) + return false; + return id.range[0] >= binding.range[0] && id.range[1] <= binding.range[1]; +} +function analyzeDiParams(classNode, ctor, importMap, sourceCode) { + const fn = ctor.value; + if (fn.type !== 'FunctionExpression' || !fn.body) + return []; + const hasExtends = classNode.superClass != null; + const parentMap = buildParentMap(fn.body); + const firstSuper = hasExtends ? findFirstSuperCall(fn.body) : null; + const scopeManager = sourceCode.scopeManager; + const fnScope = scopeManager?.acquire(fn); + const result = []; + for (const param of fn.params) { + if (!isDiConstructorParam(param, importMap)) + continue; + const name = paramName(param); + if (!name) + continue; + const decs = allDecoratorsOnParam(param); + const { unsupported: decUnsup, flags, attributeStringArg, hasInject, } = collectParamDecoratorFlags(decs, importMap); + let unsupportedDecorator = decUnsup; + const binding = getBindingFromParam(param); + const typeAnn = binding?.typeAnnotation; + const injectFromDecor = injectTokenFromInjectDecorator(decs, importMap, sourceCode); + const usesAttributeDecorator = decs.some((d) => getDecoratorImportedName(d, importMap) === 'Attribute'); + if (binding?.optional) + flags.optional = true; + let injectFirstArg = null; + if (usesAttributeDecorator && attributeStringArg !== null) { + injectFirstArg = `new HostAttributeToken(${JSON.stringify(attributeStringArg)})`; + if (hasInject) + unsupportedDecorator = true; + } + else { + injectFirstArg = injectFromDecor ?? typeRefToInjectArg(typeAnn, sourceCode); + } + if (!injectFirstArg) + unsupportedDecorator = true; + let unsafeForSuperField = false; + if (firstSuper && fnScope) { + const variable = fnScope.variables.find((vi) => vi.name === name && vi.defs.some((def) => def.type === 'Parameter')); + if (variable) { + for (const ref of variable.references) { + if (ref.identifier.type !== 'Identifier') + continue; + if (refIsWithinParamBinding(ref.identifier, binding)) + continue; + if (!ref.isRead()) + continue; + if (isParamRefUnsafeForSuperField(ref.identifier, firstSuper, parentMap)) { + unsafeForSuperField = true; + break; + } + } + } + } + const modifiers = param.type === 'TSParameterProperty' ? formatModifiers(param) : 'private readonly'; + result.push({ + paramNode: param, + name, + injectFirstArg, + injectOptions: flags, + usesAttributeDecorator, + modifiers, + unsupportedDecorator, + unsafeForSuperField, + }); + } + return result; +} +function formatInjectOptions(flags) { + const entries = []; + if (flags.host) + entries.push(['host', true]); + if (flags.optional) + entries.push(['optional', true]); + if (flags.self) + entries.push(['self', true]); + if (flags.skipSelf) + entries.push(['skipSelf', true]); + if (!entries.length) + return null; + const inner = entries.map(([k, v]) => `${k}: ${v}`).join(', '); + return `{ ${inner} }`; +} +//# sourceMappingURL=injection-context.utils.js.map \ No newline at end of file diff --git a/lib/rules/injection-context.utils.js.map b/lib/rules/injection-context.utils.js.map new file mode 100644 index 0000000..ffb46d1 --- /dev/null +++ b/lib/rules/injection-context.utils.js.map @@ -0,0 +1 @@ +{"version":3,"file":"injection-context.utils.js","sourceRoot":"","sources":["../../src/rules/injection-context.utils.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/lib/rules/member-ordering.d.ts b/lib/rules/member-ordering.d.ts index 9035d55..3fdd610 100644 --- a/lib/rules/member-ordering.d.ts +++ b/lib/rules/member-ordering.d.ts @@ -22,7 +22,7 @@ export declare const messages: { }; export declare const rule: ESLintUtils.RuleModule<"wrongOrder" | "unknownCategory", [{ decorators: ("Component" | "Directive" | "Injectable" | "Pipe")[]; - order: ("constructor" | "inject" | "input-signal" | "input-decorator" | "output-signal" | "output-decorator" | "model-signal" | "host-binding-signal" | "host-binding-decorator" | "host-listener-signal" | "host-listener-decorator" | "view-query-signal" | "view-query-decorator" | "content-query-signal" | "content-query-decorator" | "store-select-signal" | "store-select-observable" | "store-select-decorator" | "signal" | "linkedSignal" | "computed" | "public-static-field" | "protected-static-field" | "private-static-field" | "public-instance-field" | "protected-instance-field" | "private-instance-field" | "getter-setter" | "abstract" | "public-static-method" | "protected-static-method" | "private-static-method" | "public-instance-method" | "protected-instance-method" | "private-instance-method")[]; + order: ("constructor" | "computed" | "inject" | "input-signal" | "input-decorator" | "output-signal" | "output-decorator" | "model-signal" | "host-binding-signal" | "host-binding-decorator" | "host-listener-signal" | "host-listener-decorator" | "view-query-signal" | "view-query-decorator" | "content-query-signal" | "content-query-decorator" | "store-select-signal" | "store-select-observable" | "store-select-decorator" | "signal" | "linkedSignal" | "public-static-field" | "protected-static-field" | "private-static-field" | "public-instance-field" | "protected-instance-field" | "private-instance-field" | "getter-setter" | "abstract" | "public-static-method" | "protected-static-method" | "private-static-method" | "public-instance-method" | "protected-instance-method" | "private-instance-method")[]; unknownPlacement: "last"; }], unknown, ESLintUtils.RuleListener> & { name: string; diff --git a/lib/rules/prefer-inject-function.d.ts b/lib/rules/prefer-inject-function.d.ts new file mode 100644 index 0000000..4f024cd --- /dev/null +++ b/lib/rules/prefer-inject-function.d.ts @@ -0,0 +1,17 @@ +import type { TSESLint } from '@typescript-eslint/utils'; +export declare const RULE_NAME = "prefer-inject-function"; +export type PreferInjectMessageIds = 'preferInject'; +export declare const preferInjectMessages: { + preferInject: string; +}; +type RuleOptions = { + decorators?: string[]; + /** When `false`, never emit fixes. Default `true`. */ + autofix?: boolean; +}; +type OptionsTuple = [RuleOptions?]; +export declare const rule: TSESLint.RuleModule<"preferInject", OptionsTuple, unknown, TSESLint.RuleListener> & { + name: string; +}; +export {}; +//# sourceMappingURL=prefer-inject-function.d.ts.map \ No newline at end of file diff --git a/lib/rules/prefer-inject-function.d.ts.map b/lib/rules/prefer-inject-function.d.ts.map new file mode 100644 index 0000000..0a474e8 --- /dev/null +++ b/lib/rules/prefer-inject-function.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"prefer-inject-function.d.ts","sourceRoot":"","sources":["../../src/rules/prefer-inject-function.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAY,MAAM,0BAA0B,CAAC;AAmBnE,eAAO,MAAM,SAAS,2BAA2B,CAAC;AAMlD,MAAM,MAAM,sBAAsB,GAAG,cAAc,CAAC;AAEpD,eAAO,MAAM,oBAAoB;;CAEiB,CAAC;AAEnD,KAAK,WAAW,GAAG;IACf,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,sDAAsD;IACtD,OAAO,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,KAAK,YAAY,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC;AAuQnC,eAAO,MAAM,IAAI;;CA0Ef,CAAC"} \ No newline at end of file diff --git a/lib/rules/prefer-inject-function.js b/lib/rules/prefer-inject-function.js new file mode 100644 index 0000000..99bcf8e --- /dev/null +++ b/lib/rules/prefer-inject-function.js @@ -0,0 +1,301 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.rule = exports.preferInjectMessages = exports.RULE_NAME = void 0; +const utils_1 = require("@typescript-eslint/utils"); +const injection_context_utils_1 = require("./injection-context.utils"); +exports.RULE_NAME = 'prefer-inject-function'; +const createRule = utils_1.ESLintUtils.RuleCreator((name) => `https://github.com/Leritas/eslint-plugin-angular-class-ordering/blob/main/docs/rules/${name}.md`); +exports.preferInjectMessages = { + preferInject: 'Prefer `inject()` instead of constructor injection for `{{name}}`.{{details}}', +}; +function findAngularCoreImport(program) { + for (const s of program.body) { + if (s.type === 'ImportDeclaration' && s.source.value === injection_context_utils_1.ANGULAR_CORE) + return s; + } + return null; +} +function collectImportFixes(program, need) { + const fixes = []; + if (!need.inject && !need.hostAttributeToken) + return fixes; + const existing = findAngularCoreImport(program); + const importedNames = new Set(); + if (existing) { + for (const spec of existing.specifiers ?? []) { + if (spec.type === 'ImportSpecifier') { + const imp = spec.imported; + const iname = imp.type === 'Identifier' + ? imp.name + : imp.type === 'Literal' && typeof imp.value === 'string' + ? imp.value + : null; + if (iname) + importedNames.add(iname); + } + } + } + const toAdd = []; + if (need.inject && !importedNames.has('inject')) + toAdd.push('inject'); + if (need.hostAttributeToken && !importedNames.has('HostAttributeToken')) + toAdd.push('HostAttributeToken'); + if (!toAdd.length) + return fixes; + if (!existing) { + const names = toAdd.join(', '); + fixes.push({ + range: [0, 0], + text: `import { ${names} } from '${injection_context_utils_1.ANGULAR_CORE}';\n`, + }); + return fixes; + } + const lastSpec = existing.specifiers?.[existing.specifiers.length - 1]; + if (!lastSpec?.range) + return fixes; + fixes.push({ + range: [lastSpec.range[1], lastSpec.range[1]], + text: `, ${toAdd.join(', ')}`, + }); + return fixes; +} +/** Indentation of the constructor `MethodDefinition` line inside the class body. */ +function getClassMemberIndent(sourceCode, member) { + const before = sourceCode.text.slice(0, member.range[0]); + const lineStart = before.lastIndexOf('\n') + 1; + const linePrefix = before.slice(lineStart); + return /^(\s*)/.exec(linePrefix)?.[1] ?? ' '; +} +/** Source range of the inside of `(...)` for a function (constructor) parameter list. */ +function getParamListInnerRange(sourceCode, fn) { + const offset = fn.range[0]; + const text = sourceCode.getText(fn); + let depth = 0; + let paramOpen = -1; + let paramClose = -1; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '(') { + if (depth === 0) + paramOpen = i + 1; + depth++; + } + else if (c === ')') { + depth--; + if (depth === 0 && paramOpen !== -1) { + paramClose = i; + break; + } + } + } + if (paramOpen < 0 || paramClose < 0) + return [fn.range[1], fn.range[1]]; + return [offset + paramOpen, offset + paramClose]; +} +function buildMigrateFieldsText(toMigrate, memberIndent) { + const fieldIndent = memberIndent; + return toMigrate + .map((m) => { + const opt = (0, injection_context_utils_1.formatInjectOptions)(m.injectOptions); + const second = opt ? `, ${opt}` : ''; + return `${fieldIndent}${m.modifiers} ${m.name} = inject(${m.injectFirstArg}${second});`; + }) + .join('\n'); +} +/** + * TypeScript parameter properties share one scope entry with the constructor body in eslint-scope, + * so `const svc` does not hide the parameter in `variable.references`. Detect shadowing via AST. + */ +function isShadowedByConstOrLetBeforeRef(refId, paramName, parentMap) { + let node = refId; + while (node) { + const nextParent = parentMap.get(node) ?? null; + if (!nextParent) + break; + if (nextParent.type === 'BlockStatement') { + const block = nextParent; + for (const stmt of block.body) { + if (stmt.range[0] >= refId.range[0]) + break; + if (stmt.type === 'VariableDeclaration' && (stmt.kind === 'const' || stmt.kind === 'let')) { + for (const d of stmt.declarations) { + if (d.id.type === 'Identifier' && d.id.name === paramName) + return true; + } + } + } + } + node = nextParent; + } + return false; +} +function collectThisPrefixFixes(sourceCode, fn, migrated) { + const fixes = []; + const scopeManager = sourceCode.scopeManager; + if (!scopeManager) + return fixes; + const fnScope = scopeManager.acquire(fn); + if (!fnScope) + return fixes; + const parentMap = (0, injection_context_utils_1.buildParentMap)(fn); + for (const m of migrated) { + const variable = fnScope.variables.find((vi) => vi.name === m.name && vi.defs.some((d) => d.type === 'Parameter')); + if (!variable) + continue; + const binding = (0, injection_context_utils_1.getBindingFromParam)(m.paramNode); + for (const ref of variable.references) { + if (!ref.isRead()) + continue; + if (ref.identifier.type === 'Identifier' && + isShadowedByConstOrLetBeforeRef(ref.identifier, m.name, parentMap)) + continue; + if (binding?.range && + ref.identifier.range && + ref.identifier.range[0] >= binding.range[0] && + ref.identifier.range[1] <= binding.range[1]) { + continue; + } + const id = ref.identifier; + const parent = parentMap.get(id); + if (parent?.type === 'MemberExpression' && + !parent.computed && + parent.object.type === 'ThisExpression' && + parent.property === id) { + continue; + } + if (parent?.type === 'Property' && + 'shorthand' in parent && + parent.shorthand && + parent.key === id) { + const keyText = sourceCode.getText(parent.key); + fixes.push({ + range: [parent.range[0], parent.range[1]], + text: `${keyText}: this.${m.name}`, + }); + continue; + } + fixes.push({ + range: id.range, + text: `this.${m.name}`, + }); + } + } + fixes.sort((a, b) => b.range[0] - a.range[0]); + return fixes; +} +function buildConstructorParamListFix(sourceCode, fn, toMigrate, importMap) { + const migrateSet = new Set(toMigrate.map((t) => t.paramNode)); + const kept = fn.params.filter((p) => { + if (!(0, injection_context_utils_1.isDiConstructorParam)(p, importMap)) + return true; + return !migrateSet.has(p); + }); + const innerRange = getParamListInnerRange(sourceCode, fn); + const newInner = kept.map((p) => sourceCode.getText(p)).join(', '); + return { range: innerRange, text: newInner }; +} +function buildInsertFieldsFix(sourceCode, ctor, toMigrate) { + if (!toMigrate.length) + return null; + const indent = getClassMemberIndent(sourceCode, ctor); + const fields = buildMigrateFieldsText(toMigrate, indent); + const pos = ctor.range[1]; + return { + range: [pos, pos], + text: `\n\n${fields}`, + }; +} +function buildAllFixes(sourceCode, ctor, toMigrate, importMap) { + const fn = ctor.value; + if (fn.type !== 'FunctionExpression' || !fn.body) + return []; + const need = { + inject: true, + hostAttributeToken: toMigrate.some((m) => m.usesAttributeDecorator), + }; + const program = sourceCode.ast; + const out = []; + out.push(...collectImportFixes(program, need)); + const paramFix = buildConstructorParamListFix(sourceCode, fn, toMigrate, importMap); + if (paramFix) + out.push(paramFix); + out.push(...collectThisPrefixFixes(sourceCode, fn, toMigrate)); + const insertFix = buildInsertFieldsFix(sourceCode, ctor, toMigrate); + if (insertFix) + out.push(insertFix); + out.sort((a, b) => b.range[0] - a.range[0]); + return out; +} +exports.rule = createRule({ + name: exports.RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'Prefer Angular `inject()` over constructor parameter injection.', + }, + fixable: 'code', + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + decorators: { + type: 'array', + items: { type: 'string' }, + }, + autofix: { type: 'boolean' }, + }, + }, + ], + messages: exports.preferInjectMessages, + }, + defaultOptions: [ + { + decorators: [...injection_context_utils_1.DEFAULT_INJECT_RULE_DECORATORS], + autofix: true, + }, + ], + create(context, [options = {}]) { + const decorators = options.decorators ?? [...injection_context_utils_1.DEFAULT_INJECT_RULE_DECORATORS]; + const autofix = options.autofix !== false; + const sourceCode = context.sourceCode; + const program = sourceCode.ast; + const importMap = (0, injection_context_utils_1.buildImportMap)(program); + return { + Program() { + for (const classNode of (0, injection_context_utils_1.iterateClasses)(program)) { + if (!(0, injection_context_utils_1.classMatchesDecorators)(classNode, decorators)) + continue; + const ctor = (0, injection_context_utils_1.findConstructor)(classNode.body); + if (!ctor) + continue; + const analyses = (0, injection_context_utils_1.analyzeDiParams)(classNode, ctor, importMap, sourceCode); + const reportable = analyses.filter((a) => !a.unsafeForSuperField); + const batchMigrate = reportable.filter((x) => !x.unsupportedDecorator && Boolean(x.injectFirstArg)); + let offerBatchFix = autofix && batchMigrate.length > 0; + for (const a of reportable) { + const rowFixable = !a.unsupportedDecorator && Boolean(a.injectFirstArg); + const attachFix = offerBatchFix && rowFixable; + if (attachFix) + offerBatchFix = false; + const details = a.unsupportedDecorator ? ' Unsupported decorators or token; fix manually.' : ''; + context.report({ + node: a.paramNode, + messageId: 'preferInject', + data: { name: a.name, details }, + fix: attachFix && batchMigrate.length + ? (fixer) => { + const fixes = buildAllFixes(sourceCode, ctor, batchMigrate, importMap); + if (!fixes.length) + return null; + return fixes.map((f) => fixer.replaceTextRange(f.range, f.text)); + } + : undefined, + }); + } + } + }, + }; + }, +}); +//# sourceMappingURL=prefer-inject-function.js.map \ No newline at end of file diff --git a/lib/rules/prefer-inject-function.js.map b/lib/rules/prefer-inject-function.js.map new file mode 100644 index 0000000..16b3e15 --- /dev/null +++ b/lib/rules/prefer-inject-function.js.map @@ -0,0 +1 @@ +{"version":3,"file":"prefer-inject-function.js","sourceRoot":"","sources":["../../src/rules/prefer-inject-function.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index dbe90f4..788fcbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "eslint-plugin-angular-class-ordering", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eslint-plugin-angular-class-ordering", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.18.0" + "@typescript-eslint/utils": "^8.18.0", + "@typescript-eslint/visitor-keys": "^8.18.0" }, "devDependencies": { "@types/jest": "^29.5.14", diff --git a/package.json b/package.json index 3c9d291..5024769 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "eslint-plugin-angular-class-ordering", - "version": "0.4.0", - "description": "ESLint plugin with an Angular-aware class member ordering rule and auto-fix", + "version": "0.5.0", + "description": "ESLint plugin: Angular class member ordering, prefer-inject-function, forbid-nested-super-injections", "author": "Leritas ", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -23,6 +23,8 @@ "eslintplugin", "angular", "member-ordering", + "inject", + "dependency-injection", "signals", "typescript" ], @@ -50,7 +52,8 @@ "format:check": "prettier --check ." }, "dependencies": { - "@typescript-eslint/utils": "^8.18.0" + "@typescript-eslint/utils": "^8.18.0", + "@typescript-eslint/visitor-keys": "^8.18.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0", diff --git a/src/index.ts b/src/index.ts index 5f7dc5b..6151b36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,34 @@ import { ESLint } from 'eslint'; import pkg from '../package.json'; -import { RULE_NAME, rule } from './rules/member-ordering'; +import { RULE_NAME as FORBID_NESTED_RULE_NAME, rule as forbidNestedRule } from './rules/forbid-nested-super-injections'; +import { RULE_NAME as MEMBER_ORDERING_RULE_NAME, rule as memberOrderingRule } from './rules/member-ordering'; +import { RULE_NAME as PREFER_INJECT_RULE_NAME, rule as preferInjectRule } from './rules/prefer-inject-function'; + +const pluginRules = { + [MEMBER_ORDERING_RULE_NAME]: memberOrderingRule, + [PREFER_INJECT_RULE_NAME]: preferInjectRule, + [FORBID_NESTED_RULE_NAME]: forbidNestedRule, +} as const; /** - * ESLint plugin exposing Angular-aware class member ordering. + * ESLint plugin exposing Angular-aware class member ordering and inject-related rules. */ const plugin = { meta: { name: pkg.name, version: pkg.version, }, - rules: { - [RULE_NAME]: rule, + rules: pluginRules, + configs: { + /** + * Safe default: class member layout only. `prefer-inject-function` and + * `forbid-nested-super-injections` are opt-in (they can rewrite or steer DI refactors). + */ + recommended: { + rules: { + [`${pkg.name.replace(/^eslint-plugin-/, '')}/${MEMBER_ORDERING_RULE_NAME}`]: 'error', + }, + }, }, } as unknown as ESLint.Plugin; diff --git a/src/rules/forbid-nested-super-injections.ts b/src/rules/forbid-nested-super-injections.ts new file mode 100644 index 0000000..86e3749 --- /dev/null +++ b/src/rules/forbid-nested-super-injections.ts @@ -0,0 +1,84 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; + +import { + analyzeDiParams, + buildImportMap, + classMatchesDecorators, + DEFAULT_INJECT_RULE_DECORATORS, + findConstructor, + iterateClasses, +} from './injection-context.utils'; + +export const RULE_NAME = 'forbid-nested-super-injections'; + +const createRule = ESLintUtils.RuleCreator( + (name) => `https://github.com/Leritas/eslint-plugin-angular-class-ordering/blob/main/docs/rules/${name}.md`, +); + +export type ForbidNestedMessageIds = 'forbidNestedSuperInjections'; + +export const forbidNestedMessages = { + forbidNestedSuperInjections: + 'Constructor dependency `{{name}}` is used before subclass `inject()` fields exist (e.g. in `super(...)` or earlier code). Refactor the base class to stop passing this through `super`, for example by using `inject()` on the parent; then `prefer-inject-function` can move it safely.', +} satisfies Record; + +type RuleOptions = { + decorators?: string[]; +}; + +type OptionsTuple = [RuleOptions?]; + +export const rule = createRule({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: + 'Flags constructor DI parameters that cannot be migrated to `inject()` because they are used before `super()` completes.', + }, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + decorators: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + ], + messages: forbidNestedMessages, + }, + defaultOptions: [ + { + decorators: [...DEFAULT_INJECT_RULE_DECORATORS], + }, + ], + create(context, [options = {}]) { + const decorators = options.decorators ?? [...DEFAULT_INJECT_RULE_DECORATORS]; + const sourceCode = context.sourceCode; + const program = sourceCode.ast as import('@typescript-eslint/utils').TSESTree.Program; + const importMap = buildImportMap(program); + + return { + Program() { + for (const classNode of iterateClasses(program)) { + if (!classMatchesDecorators(classNode, decorators)) continue; + const ctor = findConstructor(classNode.body); + if (!ctor) continue; + + const analyses = analyzeDiParams(classNode, ctor, importMap, sourceCode); + for (const a of analyses) { + if (!a.unsafeForSuperField) continue; + context.report({ + node: a.paramNode, + messageId: 'forbidNestedSuperInjections', + data: { name: a.name }, + }); + } + } + }, + }; + }, +}); diff --git a/src/rules/injection-context.utils.ts b/src/rules/injection-context.utils.ts new file mode 100644 index 0000000..1bea60e --- /dev/null +++ b/src/rules/injection-context.utils.ts @@ -0,0 +1,425 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { visitorKeys } from '@typescript-eslint/visitor-keys'; + +export const ANGULAR_CORE = '@angular/core'; + +export const DEFAULT_INJECT_RULE_DECORATORS = ['Component', 'Directive', 'Injectable', 'Pipe'] as const; + +export type ImportBinding = { module: string; importedName: string }; + +export function buildImportMap(programNode: TSESTree.Program): Map { + const map = new Map(); + for (const stmt of programNode.body) { + if (stmt.type !== 'ImportDeclaration' || typeof stmt.source.value !== 'string') continue; + const moduleName = stmt.source.value; + for (const spec of stmt.specifiers ?? []) { + let importedName: string | undefined; + if (spec.type === 'ImportDefaultSpecifier') importedName = 'default'; + else if (spec.type === 'ImportSpecifier') { + const imp = spec.imported; + importedName = + imp.type === 'Identifier' + ? imp.name + : imp.type === 'Literal' && typeof imp.value === 'string' + ? imp.value + : undefined; + } + if (importedName) map.set(spec.local.name, { module: moduleName, importedName }); + } + } + return map; +} + +export function* iterateClasses(programNode: TSESTree.Program): Generator { + for (const stmt of programNode.body) { + if (stmt.type === 'ClassDeclaration') yield stmt; + if ( + (stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportDefaultDeclaration') && + stmt.declaration?.type === 'ClassDeclaration' + ) { + yield stmt.declaration; + } + } +} + +export function getDecoratorNames(decorators: TSESTree.Decorator[] | undefined): string[] { + return (decorators ?? []) + .map((d): string | undefined => { + const expr = d.expression; + if (expr.type === 'CallExpression') { + if (expr.callee.type === 'Identifier') return expr.callee.name; + if ( + expr.callee.type === 'MemberExpression' && + !expr.callee.computed && + expr.callee.property.type === 'Identifier' + ) { + return expr.callee.property.name; + } + return undefined; + } + return expr.type === 'Identifier' ? expr.name : undefined; + }) + .filter((n): n is string => Boolean(n)); +} + +export function classMatchesDecorators( + classNode: TSESTree.ClassDeclaration, + decoratorNames: readonly string[], +): boolean { + const classDecorators = getDecoratorNames(classNode.decorators); + return classDecorators.some((n) => decoratorNames.includes(n)); +} + +export function getDecoratorImportedName( + dec: TSESTree.Decorator, + importMap: Map, +): string | null { + const expr = dec.expression; + if (expr.type === 'CallExpression' && expr.callee.type === 'Identifier') { + const loc = expr.callee.name; + const b = importMap.get(loc); + if (b?.module === ANGULAR_CORE) return b.importedName; + return expr.callee.name; + } + if (expr.type === 'Identifier') { + const b = importMap.get(expr.name); + if (b?.module === ANGULAR_CORE) return b.importedName; + return expr.name; + } + return null; +} + +function traverseChildren(node: TSESTree.Node, visit: (child: TSESTree.Node) => void): void { + const keys = visitorKeys[node.type] ?? []; + for (const key of keys) { + const v = (node as unknown as Record)[key]; + if (v === null || v === undefined) continue; + if (Array.isArray(v)) { + for (const item of v) { + if (item && typeof item === 'object' && item !== null && 'type' in item) { + visit(item as TSESTree.Node); + } + } + } else if (typeof v === 'object' && v !== null && 'type' in v) { + visit(v as TSESTree.Node); + } + } +} + +/** Build parent pointers for subtree (no AST mutation). */ +export function buildParentMap(root: TSESTree.Node): Map { + const parents = new Map(); + const visit = (node: TSESTree.Node, parent: TSESTree.Node | null): void => { + parents.set(node, parent); + traverseChildren(node, (child) => visit(child, node)); + }; + visit(root, null); + return parents; +} + +export function findConstructor( + classBody: TSESTree.ClassBody, +): (TSESTree.MethodDefinition & { kind: 'constructor' }) | null { + for (const el of classBody.body) { + if ( + el.type === 'MethodDefinition' && + el.kind === 'constructor' && + el.static === false && + el.value.type === 'FunctionExpression' + ) { + return el as TSESTree.MethodDefinition & { kind: 'constructor' }; + } + } + return null; +} + +export function findFirstSuperCall(body: TSESTree.BlockStatement): TSESTree.CallExpression | null { + let best: TSESTree.CallExpression | null = null; + const walk = (n: TSESTree.Node): void => { + if (n.type === 'CallExpression' && n.callee.type === 'Super') { + if (!best || n.range![0] < best.range![0]) best = n; + } + traverseChildren(n, walk); + }; + walk(body); + return best; +} + +/** + * Whether a reference to a constructor parameter must stay in the constructor + * (cannot become a field initializer) due to `super()` ordering. + */ +export function isParamRefUnsafeForSuperField( + refIdentifier: TSESTree.Identifier, + firstSuper: TSESTree.CallExpression, + parentMap: Map, +): boolean { + if (refIdentifier.range![0] >= firstSuper.range![1]) return false; + + let n: TSESTree.Node | null | undefined = refIdentifier; + while (n) { + const parentNode: TSESTree.Node | null = parentMap.get(n) ?? null; + if (!parentNode) break; + if (parentNode.type === 'CallExpression' && parentNode.callee.type === 'Super') { + return true; + } + if (parentNode.type === 'ArrowFunctionExpression' || parentNode.type === 'FunctionExpression') { + const gp = parentMap.get(parentNode) ?? null; + if (gp?.type === 'CallExpression' && gp.callee === parentNode) { + return true; + } + return false; + } + n = parentNode; + } + return refIdentifier.range![0] < firstSuper.range![1]; +} + +export type InjectOptionFlags = { + optional?: boolean; + host?: boolean; + self?: boolean; + skipSelf?: boolean; +}; + +export type DiParamAnalysis = { + paramNode: TSESTree.TSParameterProperty | TSESTree.Identifier; + name: string; + injectFirstArg: string | null; + injectOptions: InjectOptionFlags; + usesAttributeDecorator: boolean; + modifiers: string; + unsupportedDecorator: boolean; + unsafeForSuperField: boolean; +}; + +const ALLOWED_PARAM_DECORATORS = new Set(['Inject', 'Optional', 'Host', 'Self', 'SkipSelf', 'Attribute']); + +function collectParamDecoratorFlags( + decorators: TSESTree.Decorator[] | undefined, + importMap: Map, +): { + unsupported: boolean; + flags: InjectOptionFlags; + attributeStringArg: string | null; + hasInject: boolean; +} { + let unsupported = false; + const flags: InjectOptionFlags = {}; + let attributeStringArg: string | null = null; + let hasInject = false; + + for (const d of decorators ?? []) { + const imported = getDecoratorImportedName(d, importMap); + const expr = d.expression; + + if (imported === 'Inject') { + hasInject = true; + if (expr.type !== 'CallExpression' || !expr.arguments[0]) unsupported = true; + continue; + } + + if (!imported || !ALLOWED_PARAM_DECORATORS.has(imported)) { + unsupported = true; + continue; + } + + switch (imported) { + case 'Optional': + flags.optional = true; + break; + case 'Host': + flags.host = true; + break; + case 'Self': + flags.self = true; + break; + case 'SkipSelf': + flags.skipSelf = true; + break; + case 'Attribute': + if (expr.type === 'CallExpression' && expr.arguments[0]?.type === 'Literal') { + const lit = expr.arguments[0] as TSESTree.Literal; + if (typeof lit.value === 'string') attributeStringArg = lit.value; + else unsupported = true; + } else { + unsupported = true; + } + break; + default: + break; + } + } + + return { unsupported, flags, attributeStringArg, hasInject }; +} + +export function isDiConstructorParam( + node: TSESTree.Node, + importMap: Map, +): node is TSESTree.TSParameterProperty | TSESTree.Identifier { + if (node.type === 'TSParameterProperty') return true; + if (node.type === 'Identifier') { + const decs = (node as TSESTree.Identifier & { decorators?: TSESTree.Decorator[] }).decorators; + if (!decs?.length) return false; + return decs.some((d) => getDecoratorImportedName(d, importMap) === 'Inject'); + } + return false; +} + +export function getBindingFromParam(p: TSESTree.TSParameterProperty | TSESTree.Identifier): TSESTree.Identifier | null { + if (p.type === 'TSParameterProperty') { + const inner = p.parameter; + if (inner.type === 'Identifier') return inner; + if (inner.type === 'AssignmentPattern' && inner.left.type === 'Identifier') return inner.left; + return null; + } + return p; +} + +function paramName(p: TSESTree.TSParameterProperty | TSESTree.Identifier): string | null { + const id = getBindingFromParam(p); + return id?.name ?? null; +} + +function formatModifiers(p: TSESTree.TSParameterProperty): string { + const parts: string[] = []; + if (p.accessibility) parts.push(p.accessibility); + if (p.readonly) parts.push('readonly'); + return parts.join(' '); +} + +function typeRefToInjectArg( + typeAnn: TSESTree.TSTypeAnnotation | undefined, + sourceCode: TSESLint.SourceCode, +): string | null { + if (!typeAnn?.typeAnnotation) return null; + const t = typeAnn.typeAnnotation; + if (t.type === 'TSTypeReference' && t.typeName.type === 'Identifier') { + return sourceCode.getText(t.typeName); + } + if (t.type === 'TSTypeReference' && t.typeName.type === 'TSQualifiedName') { + return sourceCode.getText(t.typeName); + } + return null; +} + +function injectTokenFromInjectDecorator( + decorators: TSESTree.Decorator[] | undefined, + importMap: Map, + sourceCode: TSESLint.SourceCode, +): string | null { + for (const d of decorators ?? []) { + if (getDecoratorImportedName(d, importMap) !== 'Inject') continue; + const expr = d.expression; + if (expr.type !== 'CallExpression' || !expr.arguments[0]) return null; + return sourceCode.getText(expr.arguments[0]); + } + return null; +} + +function allDecoratorsOnParam(p: TSESTree.TSParameterProperty | TSESTree.Identifier): TSESTree.Decorator[] { + if (p.type === 'TSParameterProperty') return p.decorators ?? []; + return (p as TSESTree.Identifier & { decorators?: TSESTree.Decorator[] }).decorators ?? []; +} + +function refIsWithinParamBinding(id: TSESTree.Identifier, binding: TSESTree.Identifier | null): boolean { + if (!binding?.range || !id.range) return false; + return id.range[0] >= binding.range[0] && id.range[1] <= binding.range[1]; +} + +export function analyzeDiParams( + classNode: TSESTree.ClassDeclaration, + ctor: TSESTree.MethodDefinition & { kind: 'constructor' }, + importMap: Map, + sourceCode: TSESLint.SourceCode, +): DiParamAnalysis[] { + const fn = ctor.value; + if (fn.type !== 'FunctionExpression' || !fn.body) return []; + + const hasExtends = classNode.superClass != null; + const parentMap = buildParentMap(fn.body); + const firstSuper = hasExtends ? findFirstSuperCall(fn.body) : null; + + const scopeManager = sourceCode.scopeManager; + const fnScope = scopeManager?.acquire(fn); + const result: DiParamAnalysis[] = []; + + for (const param of fn.params) { + if (!isDiConstructorParam(param, importMap)) continue; + + const name = paramName(param); + if (!name) continue; + + const decs = allDecoratorsOnParam(param); + const { + unsupported: decUnsup, + flags, + attributeStringArg, + hasInject, + } = collectParamDecoratorFlags(decs, importMap); + let unsupportedDecorator = decUnsup; + + const binding = getBindingFromParam(param); + const typeAnn = binding?.typeAnnotation; + + const injectFromDecor = injectTokenFromInjectDecorator(decs, importMap, sourceCode); + const usesAttributeDecorator = decs.some((d) => getDecoratorImportedName(d, importMap) === 'Attribute'); + + if (binding?.optional) flags.optional = true; + + let injectFirstArg: string | null = null; + if (usesAttributeDecorator && attributeStringArg !== null) { + injectFirstArg = `new HostAttributeToken(${JSON.stringify(attributeStringArg)})`; + if (hasInject) unsupportedDecorator = true; + } else { + injectFirstArg = injectFromDecor ?? typeRefToInjectArg(typeAnn, sourceCode); + } + + if (!injectFirstArg) unsupportedDecorator = true; + + let unsafeForSuperField = false; + if (firstSuper && fnScope) { + const variable = fnScope.variables.find( + (vi) => vi.name === name && vi.defs.some((def) => def.type === 'Parameter'), + ); + if (variable) { + for (const ref of variable.references) { + if (ref.identifier.type !== 'Identifier') continue; + if (refIsWithinParamBinding(ref.identifier, binding)) continue; + if (!ref.isRead()) continue; + if (isParamRefUnsafeForSuperField(ref.identifier, firstSuper, parentMap)) { + unsafeForSuperField = true; + break; + } + } + } + } + + const modifiers = param.type === 'TSParameterProperty' ? formatModifiers(param) : 'private readonly'; + + result.push({ + paramNode: param, + name, + injectFirstArg, + injectOptions: flags, + usesAttributeDecorator, + modifiers, + unsupportedDecorator, + unsafeForSuperField, + }); + } + + return result; +} + +export function formatInjectOptions(flags: InjectOptionFlags): string | null { + const entries: [string, boolean][] = []; + if (flags.host) entries.push(['host', true]); + if (flags.optional) entries.push(['optional', true]); + if (flags.self) entries.push(['self', true]); + if (flags.skipSelf) entries.push(['skipSelf', true]); + if (!entries.length) return null; + const inner = entries.map(([k, v]) => `${k}: ${v}`).join(', '); + return `{ ${inner} }`; +} diff --git a/src/rules/prefer-inject-function.ts b/src/rules/prefer-inject-function.ts new file mode 100644 index 0000000..a48c5cd --- /dev/null +++ b/src/rules/prefer-inject-function.ts @@ -0,0 +1,375 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { ESLintUtils } from '@typescript-eslint/utils'; + +import { + analyzeDiParams, + ANGULAR_CORE, + buildImportMap, + buildParentMap, + classMatchesDecorators, + DEFAULT_INJECT_RULE_DECORATORS, + findConstructor, + formatInjectOptions, + getBindingFromParam, + isDiConstructorParam, + iterateClasses, + type DiParamAnalysis, + type ImportBinding, +} from './injection-context.utils'; + +export const RULE_NAME = 'prefer-inject-function'; + +const createRule = ESLintUtils.RuleCreator( + (name) => `https://github.com/Leritas/eslint-plugin-angular-class-ordering/blob/main/docs/rules/${name}.md`, +); + +export type PreferInjectMessageIds = 'preferInject'; + +export const preferInjectMessages = { + preferInject: 'Prefer `inject()` instead of constructor injection for `{{name}}`.{{details}}', +} satisfies Record; + +type RuleOptions = { + decorators?: string[]; + /** When `false`, never emit fixes. Default `true`. */ + autofix?: boolean; +}; + +type OptionsTuple = [RuleOptions?]; + +function findAngularCoreImport(program: TSESTree.Program): TSESTree.ImportDeclaration | null { + for (const s of program.body) { + if (s.type === 'ImportDeclaration' && s.source.value === ANGULAR_CORE) return s; + } + return null; +} + +type ImportNeed = { inject: boolean; hostAttributeToken: boolean }; + +function collectImportFixes(program: TSESTree.Program, need: ImportNeed): TSESLint.RuleFix[] { + const fixes: TSESLint.RuleFix[] = []; + if (!need.inject && !need.hostAttributeToken) return fixes; + + const existing = findAngularCoreImport(program); + const importedNames = new Set(); + + if (existing) { + for (const spec of existing.specifiers ?? []) { + if (spec.type === 'ImportSpecifier') { + const imp = spec.imported; + const iname = + imp.type === 'Identifier' + ? imp.name + : imp.type === 'Literal' && typeof imp.value === 'string' + ? imp.value + : null; + if (iname) importedNames.add(iname); + } + } + } + + const toAdd: string[] = []; + if (need.inject && !importedNames.has('inject')) toAdd.push('inject'); + if (need.hostAttributeToken && !importedNames.has('HostAttributeToken')) toAdd.push('HostAttributeToken'); + + if (!toAdd.length) return fixes; + + if (!existing) { + const names = toAdd.join(', '); + fixes.push({ + range: [0, 0], + text: `import { ${names} } from '${ANGULAR_CORE}';\n`, + }); + return fixes; + } + + const lastSpec = existing.specifiers?.[existing.specifiers.length - 1]; + if (!lastSpec?.range) return fixes; + fixes.push({ + range: [lastSpec.range[1], lastSpec.range[1]], + text: `, ${toAdd.join(', ')}`, + }); + return fixes; +} + +/** Indentation of the constructor `MethodDefinition` line inside the class body. */ +function getClassMemberIndent(sourceCode: TSESLint.SourceCode, member: TSESTree.MethodDefinition): string { + const before = sourceCode.text.slice(0, member.range![0]); + const lineStart = before.lastIndexOf('\n') + 1; + const linePrefix = before.slice(lineStart); + return /^(\s*)/.exec(linePrefix)?.[1] ?? ' '; +} + +/** Source range of the inside of `(...)` for a function (constructor) parameter list. */ +function getParamListInnerRange(sourceCode: TSESLint.SourceCode, fn: TSESTree.FunctionExpression): TSESLint.AST.Range { + const offset = fn.range![0]; + const text = sourceCode.getText(fn); + let depth = 0; + let paramOpen = -1; + let paramClose = -1; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '(') { + if (depth === 0) paramOpen = i + 1; + depth++; + } else if (c === ')') { + depth--; + if (depth === 0 && paramOpen !== -1) { + paramClose = i; + break; + } + } + } + if (paramOpen < 0 || paramClose < 0) return [fn.range![1], fn.range![1]]; + return [offset + paramOpen, offset + paramClose]; +} + +function buildMigrateFieldsText(toMigrate: DiParamAnalysis[], memberIndent: string): string { + const fieldIndent = memberIndent; + return toMigrate + .map((m) => { + const opt = formatInjectOptions(m.injectOptions); + const second = opt ? `, ${opt}` : ''; + return `${fieldIndent}${m.modifiers} ${m.name} = inject(${m.injectFirstArg}${second});`; + }) + .join('\n'); +} + +/** + * TypeScript parameter properties share one scope entry with the constructor body in eslint-scope, + * so `const svc` does not hide the parameter in `variable.references`. Detect shadowing via AST. + */ +function isShadowedByConstOrLetBeforeRef( + refId: TSESTree.Identifier, + paramName: string, + parentMap: Map, +): boolean { + let node: TSESTree.Node | null | undefined = refId; + while (node) { + const nextParent: TSESTree.Node | null = parentMap.get(node) ?? null; + if (!nextParent) break; + if (nextParent.type === 'BlockStatement') { + const block = nextParent; + for (const stmt of block.body) { + if (stmt.range![0] >= refId.range![0]) break; + if (stmt.type === 'VariableDeclaration' && (stmt.kind === 'const' || stmt.kind === 'let')) { + for (const d of stmt.declarations) { + if (d.id.type === 'Identifier' && d.id.name === paramName) return true; + } + } + } + } + node = nextParent; + } + return false; +} + +function collectThisPrefixFixes( + sourceCode: TSESLint.SourceCode, + fn: TSESTree.FunctionExpression, + migrated: DiParamAnalysis[], +): TSESLint.RuleFix[] { + const fixes: TSESLint.RuleFix[] = []; + const scopeManager = sourceCode.scopeManager; + if (!scopeManager) return fixes; + + const fnScope = scopeManager.acquire(fn); + if (!fnScope) return fixes; + + const parentMap = buildParentMap(fn); + + for (const m of migrated) { + const variable = fnScope.variables.find( + (vi) => vi.name === m.name && vi.defs.some((d) => d.type === 'Parameter'), + ); + if (!variable) continue; + + const binding = getBindingFromParam(m.paramNode); + + for (const ref of variable.references) { + if (!ref.isRead()) continue; + if ( + ref.identifier.type === 'Identifier' && + isShadowedByConstOrLetBeforeRef(ref.identifier, m.name, parentMap) + ) + continue; + if ( + binding?.range && + ref.identifier.range && + ref.identifier.range[0] >= binding.range[0] && + ref.identifier.range[1] <= binding.range[1] + ) { + continue; + } + const id = ref.identifier; + const parent = parentMap.get(id); + if ( + parent?.type === 'MemberExpression' && + !parent.computed && + parent.object.type === 'ThisExpression' && + parent.property === id + ) { + continue; + } + if ( + parent?.type === 'Property' && + 'shorthand' in parent && + (parent as TSESTree.Property).shorthand && + (parent as TSESTree.Property).key === id + ) { + const keyText = sourceCode.getText((parent as TSESTree.Property).key); + fixes.push({ + range: [parent.range![0], parent.range![1]], + text: `${keyText}: this.${m.name}`, + }); + continue; + } + fixes.push({ + range: id.range!, + text: `this.${m.name}`, + }); + } + } + + fixes.sort((a, b) => b.range[0] - a.range[0]); + return fixes; +} + +function buildConstructorParamListFix( + sourceCode: TSESLint.SourceCode, + fn: TSESTree.FunctionExpression, + toMigrate: DiParamAnalysis[], + importMap: Map, +): TSESLint.RuleFix | null { + const migrateSet = new Set(toMigrate.map((t) => t.paramNode)); + const kept = fn.params.filter((p) => { + if (!isDiConstructorParam(p, importMap)) return true; + return !migrateSet.has(p); + }); + + const innerRange = getParamListInnerRange(sourceCode, fn); + const newInner = kept.map((p) => sourceCode.getText(p)).join(', '); + return { range: innerRange, text: newInner }; +} + +function buildInsertFieldsFix( + sourceCode: TSESLint.SourceCode, + ctor: TSESTree.MethodDefinition, + toMigrate: DiParamAnalysis[], +): TSESLint.RuleFix | null { + if (!toMigrate.length) return null; + const indent = getClassMemberIndent(sourceCode, ctor); + const fields = buildMigrateFieldsText(toMigrate, indent); + const pos = ctor.range![1]; + return { + range: [pos, pos], + text: `\n\n${fields}`, + }; +} + +function buildAllFixes( + sourceCode: TSESLint.SourceCode, + ctor: TSESTree.MethodDefinition & { kind: 'constructor' }, + toMigrate: DiParamAnalysis[], + importMap: Map, +): TSESLint.RuleFix[] { + const fn = ctor.value; + if (fn.type !== 'FunctionExpression' || !fn.body) return []; + + const need: ImportNeed = { + inject: true, + hostAttributeToken: toMigrate.some((m) => m.usesAttributeDecorator), + }; + + const program = sourceCode.ast as TSESTree.Program; + const out: TSESLint.RuleFix[] = []; + + out.push(...collectImportFixes(program, need)); + + const paramFix = buildConstructorParamListFix(sourceCode, fn, toMigrate, importMap); + if (paramFix) out.push(paramFix); + + out.push(...collectThisPrefixFixes(sourceCode, fn, toMigrate)); + + const insertFix = buildInsertFieldsFix(sourceCode, ctor, toMigrate); + if (insertFix) out.push(insertFix); + + out.sort((a, b) => b.range[0] - a.range[0]); + return out; +} + +export const rule = createRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'Prefer Angular `inject()` over constructor parameter injection.', + }, + fixable: 'code', + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + decorators: { + type: 'array', + items: { type: 'string' }, + }, + autofix: { type: 'boolean' }, + }, + }, + ], + messages: preferInjectMessages, + }, + defaultOptions: [ + { + decorators: [...DEFAULT_INJECT_RULE_DECORATORS], + autofix: true, + }, + ], + create(context, [options = {}]) { + const decorators = options.decorators ?? [...DEFAULT_INJECT_RULE_DECORATORS]; + const autofix = options.autofix !== false; + const sourceCode = context.sourceCode; + const program = sourceCode.ast as TSESTree.Program; + const importMap = buildImportMap(program); + + return { + Program() { + for (const classNode of iterateClasses(program)) { + if (!classMatchesDecorators(classNode, decorators)) continue; + const ctor = findConstructor(classNode.body); + if (!ctor) continue; + + const analyses = analyzeDiParams(classNode, ctor, importMap, sourceCode); + const reportable = analyses.filter((a) => !a.unsafeForSuperField); + const batchMigrate = reportable.filter((x) => !x.unsupportedDecorator && Boolean(x.injectFirstArg)); + + let offerBatchFix = autofix && batchMigrate.length > 0; + + for (const a of reportable) { + const rowFixable = !a.unsupportedDecorator && Boolean(a.injectFirstArg); + const attachFix = offerBatchFix && rowFixable; + if (attachFix) offerBatchFix = false; + + const details = a.unsupportedDecorator ? ' Unsupported decorators or token; fix manually.' : ''; + + context.report({ + node: a.paramNode, + messageId: 'preferInject', + data: { name: a.name, details }, + fix: + attachFix && batchMigrate.length + ? (fixer) => { + const fixes = buildAllFixes(sourceCode, ctor, batchMigrate, importMap); + if (!fixes.length) return null; + return fixes.map((f) => fixer.replaceTextRange(f.range, f.text)); + } + : undefined, + }); + } + } + }, + }; + }, +}); diff --git a/tests/member-ordering.test.ts b/tests/member-ordering.test.ts index 3af8d2e..3bc3c09 100644 --- a/tests/member-ordering.test.ts +++ b/tests/member-ordering.test.ts @@ -420,6 +420,46 @@ export class X { @ContentChildren('item') items!: unknown; } +`, + }, + { + name: 'call named inject not from @angular/core is ordinary field, not inject slot', + code: ` +import { Component, inject as ngInject } from '@angular/core'; + +class Service {} + +function inject(t: T): T { + return t; +} + +@Component({ selector: 'app-x', template: '' }) +export class X { + constructor() {} + + private readonly fromCore = ngInject(Service); + + fromLocalFn = inject(Service); +} +`, + }, + { + name: 'static block alongside members does not confuse ordering', + code: ` +import { Component, inject } from '@angular/core'; + +class Foo {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + static { + void 0; + } + + constructor() {} + + private readonly svc = inject(Foo); +} `, }, ], @@ -452,6 +492,44 @@ export class X { plain = 1; } +`, + }, + { + name: 'autofix keeps each member JSDoc attached when reordering', + code: ` +import { Component, inject, input } from '@angular/core'; + +class Foo {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + /** Documents plain field. */ + plain = 1; + + /** Documents injected service. */ + private readonly svc = inject(Foo); + + /** Documents title input. */ + readonly title = input(); +} +`, + errors: [{ messageId: 'wrongOrder' }], + output: ` +import { Component, inject, input } from '@angular/core'; + +class Foo {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + /** Documents injected service. */ + private readonly svc = inject(Foo); + + /** Documents title input. */ + readonly title = input(); + + /** Documents plain field. */ + plain = 1; +} `, }, { @@ -526,6 +604,7 @@ export class X { }, ] as any, errors: [{ messageId: 'wrongOrder' }, { messageId: 'unknownCategory' }], + output: null, }, { name: 'decorator category out of order vs inject is fixed', @@ -912,6 +991,43 @@ export class X { plain = 1; } +`, + }, + { + name: 'ternary with inject() branches classifies as public field — fix pulls core inject above', + code: ` +import { Component, inject } from '@angular/core'; + +class A {} +class B {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + constructor() {} + + flag = true; + + x = flag ? inject(A) : inject(B); + + y = inject(A); +} +`, + errors: [{ messageId: 'wrongOrder' }], + output: ` +import { Component, inject } from '@angular/core'; + +class A {} +class B {} + +@Component({ selector: 'app-x', template: '' }) +export class X { + constructor() {} + + y = inject(A); + + flag = true; + x = flag ? inject(A) : inject(B); +} `, }, ], diff --git a/tests/prefer-inject-function.test.ts b/tests/prefer-inject-function.test.ts new file mode 100644 index 0000000..02cf833 --- /dev/null +++ b/tests/prefer-inject-function.test.ts @@ -0,0 +1,530 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import parser from '@typescript-eslint/parser'; +import { rule as forbidNestedRule } from '../src/rules/forbid-nested-super-injections'; +import { rule as preferInjectRule } from '../src/rules/prefer-inject-function'; + +const ruleTester = new RuleTester({ + languageOptions: { + parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + project: false, + }, + }, +} as any); + +ruleTester.run('prefer-inject-function', preferInjectRule, { + valid: [ + { + name: 'ignores class without Angular decorator', + code: ` +class Plain { + constructor(public x: number) {} +} +`, + }, + { + name: 'already uses inject field', + code: ` +import { Component, inject } from '@angular/core'; +class S {} +@Component({ template: '' }) +export class X { + constructor() {} + private readonly s = inject(S); +} +`, + }, + { + name: 'no prefer-inject when every DI param is unsafe (super forwards all)', + code: ` +import { Component } from '@angular/core'; +class Base {} +class A {} +class B {} +@Component({ template: '' }) +export class X extends Base { + constructor(private readonly a: A, private b: B) { + super(a, b); + } +} +`, + }, + ], + invalid: [ + { + name: 'migrates simple private readonly service param', + code: ` +import { Component } from '@angular/core'; +class Svc {} +@Component({ template: '' }) +export class X { + constructor(private readonly svc: Svc) {} +} +`, + output: ` +import { Component, inject } from '@angular/core'; +class Svc {} +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly svc = inject(Svc); +} +`, + errors: [{ messageId: 'preferInject' }], + }, + { + name: 'rewrites bare identifier to this in constructor body', + code: ` +import { Component, Inject } from '@angular/core'; +const APP_ENV = { adminMetrikaCounter: 1 }; +class AnalyticsService { analyticsStats = {}; } +@Component({ template: '' }) +export class X { + constructor( + private readonly analyticsService: AnalyticsService, + @Inject(APP_ENV) private readonly environment: typeof APP_ENV, + ) { + if (this.environment.adminMetrikaCounter) { + this.analyticsService.analyticsStats = { metrikaId: environment.adminMetrikaCounter }; + } + } +} +`, + output: ` +import { Component, Inject, inject } from '@angular/core'; +const APP_ENV = { adminMetrikaCounter: 1 }; +class AnalyticsService { analyticsStats = {}; } +@Component({ template: '' }) +export class X { + constructor() { + if (this.environment.adminMetrikaCounter) { + this.analyticsService.analyticsStats = { metrikaId: this.environment.adminMetrikaCounter }; + } + } + + private readonly analyticsService = inject(AnalyticsService); + private readonly environment = inject(APP_ENV); +} +`, + errors: [{ messageId: 'preferInject' }, { messageId: 'preferInject' }], + }, + { + name: '@Optional maps to inject option', + code: ` +import { Component, Optional } from '@angular/core'; +class Store {} +@Component({ template: '' }) +export class X { + constructor(@Optional() private readonly store: Store) {} +} +`, + output: ` +import { Component, Optional, inject } from '@angular/core'; +class Store {} +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly store = inject(Store, { optional: true }); +} +`, + errors: [{ messageId: 'preferInject' }], + }, + { + name: '@Host and @Self flags', + code: ` +import { Component, Host, Self } from '@angular/core'; +class Tok {} +@Component({ template: '' }) +export class X { + constructor(@Host() @Self() private readonly t: Tok) {} +} +`, + output: ` +import { Component, Host, Self, inject } from '@angular/core'; +class Tok {} +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly t = inject(Tok, { host: true, self: true }); +} +`, + errors: [{ messageId: 'preferInject' }], + }, + { + name: 'partial migration when sibling used in super', + code: ` +import { Component } from '@angular/core'; +class Base {} +class D {} +class Y {} +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D, private readonly y: Y) { + super(d); + this.y.use(); + } +} +`, + output: ` +import { Component, inject } from '@angular/core'; +class Base {} +class D {} +class Y {} +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + super(d); + this.y.use(); + } + + private readonly y = inject(Y); +} +`, + errors: [{ messageId: 'preferInject' }], + }, + { + name: 'autofix false yields no output change', + code: ` +import { Component } from '@angular/core'; +class Svc {} +@Component({ template: '' }) +export class X { + constructor(private readonly svc: Svc) {} +} +`, + options: [{ autofix: false }], + errors: [{ messageId: 'preferInject' }], + }, + { + name: 'default param value with nested parens and arrow does not break param list fix', + code: ` +import { Component } from '@angular/core'; +class Svc {} +function makeSvc(_fn: () => number): Svc { + return {} as Svc; +} +@Component({ template: '' }) +export class X { + constructor(private readonly svc: Svc = makeSvc(() => 1)) {} +} +`, + output: ` +import { Component, inject } from '@angular/core'; +class Svc {} +function makeSvc(_fn: () => number): Svc { + return {} as Svc; +} +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly svc = inject(Svc); +} +`, + errors: [{ messageId: 'preferInject' }], + }, + { + name: 'does not prefix this when constructor param name is shadowed locally', + code: ` +import { Component } from '@angular/core'; +class Svc { + id = 'field'; +} +@Component({ template: '' }) +export class X { + constructor(private readonly svc: Svc) { + const svc = { id: 'shadow' }; + console.log(svc.id); + console.log(this.svc.id); + } +} +`, + output: ` +import { Component, inject } from '@angular/core'; +class Svc { + id = 'field'; +} +@Component({ template: '' }) +export class X { + constructor() { + const svc = { id: 'shadow' }; + console.log(svc.id); + console.log(this.svc.id); + } + + private readonly svc = inject(Svc); +} +`, + errors: [{ messageId: 'preferInject' }], + }, + { + name: 'batch migrates multiple DI params in one fix', + code: ` +import { Component, Inject } from '@angular/core'; +class A {} +class B {} +const TOK = {}; +@Component({ template: '' }) +export class X { + constructor( + private readonly a: A, + private b: B, + @Inject(TOK) private readonly token: unknown, + ) {} +} +`, + output: ` +import { Component, Inject, inject } from '@angular/core'; +class A {} +class B {} +const TOK = {}; +@Component({ template: '' }) +export class X { + constructor() {} + + private readonly a = inject(A); + private b = inject(B); + private readonly token = inject(TOK); +} +`, + errors: [{ messageId: 'preferInject' }, { messageId: 'preferInject' }, { messageId: 'preferInject' }], + }, + { + name: 'union type param is reported without autofix', + code: ` +import { Component } from '@angular/core'; +class A {} +class B {} +@Component({ template: '' }) +export class X { + constructor(private readonly x: A | B) {} +} +`, + errors: [{ messageId: 'preferInject' }], + }, + { + name: '@Inject combined with @Attribute is manual (no fix)', + code: ` +import { Attribute, Component, Inject } from '@angular/core'; +const TOKEN = {}; +@Component({ template: '' }) +export class X { + constructor(@Inject(TOKEN) @Attribute('role') private readonly x: string) {} +} +`, + errors: [{ messageId: 'preferInject' }], + }, + { + name: 'preserves public and protected parameter property modifiers', + code: ` +import { Component } from '@angular/core'; +class Pub {} +class Prot {} +class Priv {} +@Component({ template: '' }) +export class X { + constructor( + public readonly pub: Pub, + protected prot: Prot, + private readonly priv: Priv, + ) {} +} +`, + output: ` +import { Component, inject } from '@angular/core'; +class Pub {} +class Prot {} +class Priv {} +@Component({ template: '' }) +export class X { + constructor() {} + + public readonly pub = inject(Pub); + protected prot = inject(Prot); + private readonly priv = inject(Priv); +} +`, + errors: [{ messageId: 'preferInject' }, { messageId: 'preferInject' }, { messageId: 'preferInject' }], + }, + ], +}); + +ruleTester.run('forbid-nested-super-injections', forbidNestedRule, { + valid: [ + { + name: 'no extends', + code: ` +import { Component } from '@angular/core'; +class Svc {} +@Component({ template: '' }) +export class X { + constructor(private readonly svc: Svc) {} +} +`, + }, + { + name: 'extends but dependency only after super', + code: ` +import { Component } from '@angular/core'; +class Base {} +class Y {} +@Component({ template: '' }) +export class X extends Base { + constructor(private readonly y: Y) { + super(); + this.y.x(); + } +} +`, + }, + { + name: 'ignores non-Angular class even with extends and super(arg)', + code: ` +class Base {} +class D {} +export class Plain extends Base { + constructor(private d: D) { + super(d); + } +} +`, + }, + { + name: 'param only referenced inside non-invoked arrow before super — not unsafe', + code: ` +import { Component } from '@angular/core'; +class Base {} +class D {} +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + const fn = () => d; + super(); + void fn; + } +} +`, + }, + { + name: 'closure invoked before super is still valid — heuristic does not track call-to-closure dataflow', + code: ` +import { Component } from '@angular/core'; +class Base {} +class D {} +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + const f = () => d; + f(); + super(); + } +} +`, + }, + ], + invalid: [ + { + name: 'param passed to super', + code: ` +import { Component } from '@angular/core'; +class Base {} +class D {} +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + super(d); + } +} +`, + errors: [{ messageId: 'forbidNestedSuperInjections' }], + }, + { + name: 'param read before super line', + code: ` +import { Component } from '@angular/core'; +class Base {} +class D {} +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + console.log(d); + super(); + } +} +`, + errors: [{ messageId: 'forbidNestedSuperInjections' }], + }, + { + name: 'two DI params both used in super arguments', + code: ` +import { Component } from '@angular/core'; +class Base {} +class A {} +class B {} +@Component({ template: '' }) +export class X extends Base { + constructor(private a: A, private b: B) { + super(a, b); + } +} +`, + errors: [{ messageId: 'forbidNestedSuperInjections' }, { messageId: 'forbidNestedSuperInjections' }], + }, + { + name: '@Inject param forwarded into super', + code: ` +import { Component, Inject } from '@angular/core'; +class Base {} +const TOKEN = {}; +@Component({ template: '' }) +export class X extends Base { + constructor(@Inject(TOKEN) private readonly dep: unknown) { + super(dep); + } +} +`, + errors: [{ messageId: 'forbidNestedSuperInjections' }], + }, + { + name: 'param read inside IIFE before super', + code: ` +import { Component } from '@angular/core'; +class Base {} +class D {} +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + (() => d)(); + super(); + } +} +`, + errors: [{ messageId: 'forbidNestedSuperInjections' }], + }, + { + name: 'param read inside function declaration before super — snapshot (stricter than non-invoked arrow)', + code: ` +import { Component } from '@angular/core'; +class Base {} +class D {} +@Component({ template: '' }) +export class X extends Base { + constructor(private d: D) { + function inner() { + return d; + } + void inner; + super(); + } +} +`, + errors: [{ messageId: 'forbidNestedSuperInjections' }], + }, + ], +});