eslint-plugin-ng-onpush-migration
v1.0.0
Published
ESLint plugin that flags Angular upgrade risk and OnPush performance debt — nested state mutation, RxJS/async callback reassignment, and change-detection hazards — across Angular v6 through v22.
Maintainers
Readme
ng-onpush-migration-tool
An ESLint plugin that flags Angular upgrade risk and performance debt in component classes — starting with the change-detection hazards that break silently the moment a component moves to OnPush.
What this project does
Angular codebases accumulate the same handful of problems: state mutated in place, change detection quietly skipped, and code that relies on patterns Angular is moving away from. This plugin makes those problems visible with AST-based rules that work across Angular v6 through v22, without branching on Angular version.
The first rule category — nested mutation and OnPush-unsafe reassignment — is what's implemented today, as four focused rules that can be enabled independently:
nested-object-mutation- Example:
this.state.user.name = value - Example:
this.items.push(value)— not an object mutation itself, but a mutating array method call that Angular'sOnPushchange detection won't see either, so it's flagged the same way
- Example:
rxjs-callback-reassignment- Example:
of(1).pipe(map(() => { this.value = ... }))
- Example:
async-callback-reassignment- Example:
setTimeout(() => { this.value = 1 }, 0)
- Example:
component-state-reassignment- Example:
this.value = name— a direct reassignment of a component class property holding a primitive value (string, number, boolean); reassigning to a new object or array literal is handled bynested-object-mutationinstead
- Example:
Installation
Install the plugin as a dev dependency:
npm install --save-dev eslint-plugin-ng-onpush-migrationMinimum supported versions
The rules themselves are Angular-version-agnostic — they check plain TypeScript AST shapes (decorators, assignment expressions, member/call expressions) that have been stable since early TypeScript, so they apply equally to an Angular v6 codebase and a v22 one. What actually gates support is the linting toolchain, independent of whatever Angular/TypeScript version the application itself builds with:
| Tool | Minimum version | Why |
| --- | --- | --- |
| Node.js | 18.18.0 | Required by @typescript-eslint/utils v8's engines field — the plugin's rules require() it at runtime |
| npm | 9.8.1 | Bundled with Node 18.18.0; not an independent constraint of its own, just the practical floor that comes with the Node requirement above |
| TypeScript | 4.8.4 | Lower bound of @typescript-eslint/utils v8's peer range (>=4.8.4 <6.1.0). This is only a linting-time devDependency — a project can keep an older TypeScript for its actual build and still add a newer one solely for eslint to use |
| ESLint | 8.57.0 | Lower bound of @typescript-eslint/utils v8's peer range on eslint (^8.57.0 \|\| ^9.0.0); both the flat-config and .eslintrc.json paths documented below work at this floor |
If a project can't clear the Node 18.18.0 floor at all — not even in a dedicated CI lint step or a local shell with a second Node binary available — none of the above can be worked around with dependency versions alone, since it's an engines requirement on the package itself, not just a peer-range compatibility question.
Linting with an old pinned Node version
Plenty of enterprise Angular v6/v7/v8 codebases are pinned to Node 12 or 14 project-wide because bumping Node — not TypeScript — is what breaks the build (native node-gyp addons, node-sass, webpack 4, and similar). This plugin doesn't need the build to move to Node 18.18+; it only needs whatever process actually executes eslint to satisfy that floor. That process is usually one of three things, and each has a different, low-friction answer — no company-wide Node upgrade, and no Node version manager (nvm, volta, etc.) required anywhere:
CI. Give the lint step its own job/stage with its own Node version, separate from the build job. Every major CI platform already supports this natively — GitHub Actions'
setup-node, GitLab's per-jobimage:, Azure Pipelines'UseNode@N— each just fetches/caches the requested Node binary for that one job. It has no effect on the build job's Node version, andangular.json/project.json's lint architect target, the ESLint config, and thenpm run lintscript itself don't change at all — only the CI YAML gains a Node-version step scoped to that job.Editor (VS Code and similar). The ESLint extension runs its own language-server process using the editor's own bundled Node runtime (or an explicit
eslint.runtime/eslint.nodePathoverride), not the project's pinned Node. Since editors have shipped a modern bundled Node for a while now, linting-on-keystroke is typically unaffected by an old project-wide Node pin — verify this once on a real machine, but it's usually already fine with no configuration changes.Local terminal and pre-commit hooks (git hooks, Husky, etc.). This is the one place a genuinely older Node is likely to be the active one. Rather than installing a version manager, vendor a second, standalone Node binary as a plain unzipped folder (Node's official downloads are a zip/tarball with no installer and no admin rights required), and point the
lintscript at it by explicit path instead of the bareeslintbinary, e.g.:{ "scripts": { "lint": "./.tools/node18/bin/node ./node_modules/.bin/eslint ." } }This changes nothing about the default
nodeon any developer'sPATH, requires no policy exception (it's just a folder containing an executable, not an installed tool), and doesn't touchangular.json/project.json's architect configuration or the ESLint config itself — only the one script line that invokes the linter.
Configuring the plugin
You can enable a curated preset or opt into individual rules.
CommonJS / legacy config
For older ESLint setups that use a CommonJS configuration file, you can register the plugin like this:
module.exports = {
plugins: ['eslint-plugin-ng-onpush-migration'],
rules: {
'eslint-plugin-ng-onpush-migration/nested-object-mutation': 'warn',
'eslint-plugin-ng-onpush-migration/rxjs-callback-reassignment': 'error',
},
};.eslintrc.json (JSON config, legacy)
This format predates ESLint's flat config system and is no longer the recommended approach, but it's still supported by ESLint for projects that haven't migrated. In the plugins array, use the package's short name — drop the eslint-plugin- prefix, since ESLint's legacy config resolver adds it back automatically when looking up the module. Rule IDs in rules then use that same short name as their prefix:
{
"plugins": ["ng-onpush-migration"],
"rules": {
"ng-onpush-migration/nested-object-mutation": "warn",
"ng-onpush-migration/rxjs-callback-reassignment": "error"
}
}To pass options such as allowFix, use the [severity, options] array form, same as in any other config format:
{
"plugins": ["ng-onpush-migration"],
"rules": {
"ng-onpush-migration/nested-object-mutation": ["warn", { "allowFix": true, "injectMissingDependency": true }]
}
}If your .eslintrc.json uses overrides (for example, to scope rules to *.ts files), declare plugins and rules inside the relevant override block rather than at the top level.
Flat config with ESLint 9+ and ES modules
If you are using a flat config in eslint.config.js, the plugin can be imported and registered like this:
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import ngOnpushMigration from 'eslint-plugin-ng-onpush-migration';
export default tseslint.config(
{
files: ['**/*.ts'],
extends: [...angular.configs.tsRecommended],
plugins: {
'ng-onpush-migration': ngOnpushMigration,
},
rules: {
'ng-onpush-migration/nested-object-mutation': 'error',
'ng-onpush-migration/rxjs-callback-reassignment': 'error',
},
},
);A plain severity string ('error' / 'warn') only turns a rule on for reporting — it leaves that rule's allowFix option at its default (false), so no suggestion is offered for it. See Enabling the fix suggestion (allowFix) below to opt a rule into it.
Enabling the fix suggestion (allowFix)
Every fixable rule (nested-object-mutation, rxjs-callback-reassignment, async-callback-reassignment, component-state-reassignment) accepts an allowFix boolean option:
allowFix: false(the default) — the rule still reports violations, but offers no suggestion.allowFix: true— the rule attaches an ESLint suggestion that insertsthis.changeDetectorRef.markForCheck();after the offending statement. The rule still reports the same diagnostic afterward (the fix does not remove the mutation itself, it only wires up change detection).
This is a suggestion, not an autofix. Every fixable rule reports through ESLint's hasSuggestions mechanism, not fixable. That means the rewrite is only ever applied when a human explicitly picks "Apply suggestion" in their editor (or an equivalent tool that reads suggestions) — eslint --fix and --fix-dry-run never touch it, regardless of allowFix. The plugin is intentionally designed to never silently rewrite a file.
To opt a rule into the suggestion in a consumer config, use the [severity, options] array form instead of a bare severity string:
rules: {
'ng-onpush-migration/nested-object-mutation': ['error', { allowFix: true }],
'ng-onpush-migration/rxjs-callback-reassignment': ['error', { allowFix: true }],
'ng-onpush-migration/component-state-reassignment': ['error', { allowFix: true }],
},To turn the suggestion back off for a rule, either drop the options object or set allowFix: false explicitly:
rules: {
'ng-onpush-migration/nested-object-mutation': 'error', // allowFix defaults to false
'ng-onpush-migration/rxjs-callback-reassignment': ['error', { allowFix: false }], // explicit
},If you adopt the recommended or all shared configs (below) instead of wiring rules by hand, allowFix: true is already baked in for every rule they enable — no extra configuration needed.
Injecting a missing ChangeDetectorRef (injectMissingDependency)
By default, if a component has no ChangeDetectorRef dependency at all, the suggestion withholds the fix rather than reference a property that doesn't exist — you still get the lint error, just no markForCheck() suggestion. If you'd rather the fix add the dependency for you, set injectMissingDependency: true alongside allowFix: true:
rules: {
'ng-onpush-migration/nested-object-mutation': ['error', { allowFix: true, injectMissingDependency: true }],
},- If the class already injects a
ChangeDetectorRef— under any name — that name is reused as-is;injectMissingDependencynever renames an existing dependency. - Otherwise, a new one is added under the default name
changeDetectorRef, using whichever DI style the class already uses elsewhere:inject(ChangeDetectorRef)if the class usesinject()for anything else, or a constructor parameter (constructor(private changeDetectorRef: ChangeDetectorRef) {}) if it doesn't. Constructor injection is also the fallback for a class with no existing dependencies of either style, since it's the one style that works across the whole Angular v6–v22 range. - If
changeDetectorRefis already taken by something else on the class, or the existing@angular/coreimport can't be safely extended, the fix is withheld instead of guessing — same as the no-dependency case withinjectMissingDependencyoff.
injectMissingDependency defaults to false and is not enabled by recommended or all — it's a separate opt-in on top of allowFix, since it can add a new class member (and possibly a new constructor) rather than only inserting a method call.
Recommended preset
The recommended config enables nested-object-mutation, rxjs-callback-reassignment, and async-callback-reassignment at warn, each with allowFix: true already set:
import ngOnpushMigration from 'eslint-plugin-ng-onpush-migration';
export default [
{
plugins: {
'ng-onpush-migration': ngOnpushMigration,
},
...ngOnpushMigration.configs.recommended,
},
];component-state-reassignment is not part of recommended — add it explicitly (see Select specific rules manually) if you want it included.
All rules
The all config enables every rule, including component-state-reassignment, at warn with allowFix: true:
import ngOnpushMigration from 'eslint-plugin-ng-onpush-migration';
export default [
{
plugins: {
'ng-onpush-migration': ngOnpushMigration,
},
...ngOnpushMigration.configs.all,
},
];Select specific rules manually
When wiring rules by hand instead of spreading a preset, each rule keeps allowFix: false by default — pass { allowFix: true } explicitly for any rule you want to offer the suggestion:
import ngOnpushMigration from 'eslint-plugin-ng-onpush-migration';
export default [
{
plugins: {
'ng-onpush-migration': ngOnpushMigration,
},
rules: {
'ng-onpush-migration/nested-object-mutation': ['warn', { allowFix: true }],
'ng-onpush-migration/rxjs-callback-reassignment': 'error', // reports only, no suggestion
},
},
];Technical documentation
For repository structure, design rationale, and implementation notes, see docs/DEVELOPMENT.md.
