@angular-modernizer/plugin-angular
v0.1.3
Published
Angular-specific analysis and performance rules for the Angular Modernization Platform
Maintainers
Readme
@angular-modernizer/plugin-angular
Angular-specific analysis rules for detecting anti-patterns, performance issues, and best practice violations in Angular applications.
Overview
This plugin provides 30 analysis rules and multiple transformation types for analyzing and modernizing Angular codebases, with a focus on:
- Change Detection Strategy optimization
- RxJS operator efficiency and memory leak prevention
- Angular decorator validation
- Component/Service architecture best practices
- Automated code transformations (constructor injection, interface extraction, DI migration)
Installation
pnpm add @angular-modernizer/plugin-angularAnalysis Rules
RxJSOptimizationRule
Rule ID: plugin-angular:rxjs-optimization
Severity: Warning
Detects inefficient RxJS operator usage across multiple anti-patterns with impact analysis and refactoring complexity estimation.
Detected Patterns
Multiple Chained Pipe Calls
Consolidate multiple .pipe().pipe() chains into a single pipe operator.
Before:
this.data$ = this.http
.get('/api/data')
.pipe(map((data) => data.items))
.pipe(filter((items) => items.length > 0))
.pipe(tap((items) => console.log(items)));After:
this.data$ = this.http.get('/api/data').pipe(
map((data) => data.items),
filter((items) => items.length > 0),
tap((items) => console.log(items)),
);Impact: Low | Refactoring Complexity: Low
Missing shareReplay for Expensive Operations
HTTP calls or computed observables subscribed multiple times without caching.
Before:
// In component
this.userData$ = this.http.get<User>('/api/user');
// In template - triggers 3 HTTP requests
<div>{{ (userData$ | async)?.name }}</div>
<div>{{ (userData$ | async)?.email }}</div>
<div>{{ (userData$ | async)?.role }}</div>After:
this.userData$ = this.http
.get<User>('/api/user')
.pipe(shareReplay({ bufferSize: 1, refCount: true }));
// Now only 1 HTTP request for all subscriptionsImpact: High | Refactoring Complexity: Low
Redundant Sequential Operators
Consecutive identical operators that should be merged.
Before:
this.filtered$ = this.items$.pipe(
map((items) => items.filter((i) => i.active)),
map((items) => items.sort((a, b) => a.name.localeCompare(b.name))),
);After:
this.filtered$ = this.items$.pipe(
map((items) =>
items.filter((i) => i.active).sort((a, b) => a.name.localeCompare(b.name)),
),
);Impact: Low | Refactoring Complexity: Low
Missing Debounce on User Input
FormControl valueChanges without debounceTime() or throttleTime().
Before:
this.searchControl.valueChanges
.pipe(switchMap((term) => this.searchService.search(term)))
.subscribe((results) => (this.results = results));After:
this.searchControl.valueChanges
.pipe(
debounceTime(300),
switchMap((term) => this.searchService.search(term)),
)
.subscribe((results) => (this.results = results));Impact: High | Refactoring Complexity: Low
Logic in Subscribe Blocks
Business logic inside .subscribe() that should use pipe operators.
Before:
this.users$.subscribe((users) => {
const activeUsers = users.filter((u) => u.active);
const sortedUsers = activeUsers.sort((a, b) => a.name.localeCompare(b.name));
this.displayUsers = sortedUsers.slice(0, 10);
});After:
this.displayUsers$ = this.users$.pipe(
map(users => users.filter(u => u.active)),
map(users => users.sort((a, b) => a.name.localeCompare(b.name))),
map(users => users.slice(0, 10))
);
// In template
<div *ngFor="let user of displayUsers$ | async">{{ user.name }}</div>Impact: Medium | Refactoring Complexity: Medium
Subscribe in Subscribe (Nested Subscriptions)
Nested observable subscriptions without flattening operators.
Before:
this.route.params.subscribe((params) => {
this.userService.getUser(params['id']).subscribe((user) => {
this.user = user;
});
});After:
this.user$ = this.route.params.pipe(
switchMap(params => this.userService.getUser(params['id']))
);
// In template
<div>{{ (user$ | async)?.name }}</div>Impact: High | Refactoring Complexity: Medium
Silent HTTP catchError
HTTP observables using catchError that returns of(null), of([]), or EMPTY without re-throwing, preventing callers from distinguishing success from failure.
Impact: High | Refactoring Complexity: Medium
Incorrect Subject Usage for State
Using Subject instead of BehaviorSubject for state management.
Before:
export class StateService {
private state$ = new Subject<AppState>();
setState(state: AppState) {
this.state$.next(state);
}
getState() {
return this.state$.asObservable();
}
}After:
export class StateService {
private state$ = new BehaviorSubject<AppState>(initialState);
setState(state: AppState) {
this.state$.next(state);
}
getState() {
return this.state$.asObservable();
}
}Impact: High | Refactoring Complexity: Low
Configuration
{
"rxjs-optimization": {
"maxPipeChains": 1, // Max allowed chained .pipe() calls
"maxSubscribeComplexity": 2, // Max statements in subscribe blocks
"requireDebounceOnUserInput": true // Enforce debounce on FormControl valueChanges
}
}PerformanceViolationRule
Rule ID: angular:performance-pattern
Severity: Warning/Error
Detects missing subscription cleanup patterns in component subscriptions and complex template getters.
Missing takeUntil Before:
export class UserComponent implements OnInit {
ngOnInit() {
this.userService.currentUser$.subscribe((user) => {
this.user = user;
}); // Memory leak - survives component destruction
}
}Missing takeUntil After:
export class UserComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
this.userService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe((user) => {
this.user = user;
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}MissingOnPushRule
Rule ID: angular:missing-onpush
Severity: High
Detects components with @Input() properties using default change detection.
Before:
@Component({
selector: 'app-user-card',
// Missing: changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
@Input() user!: User;
}After:
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
@Input() user!: User;
}ServiceWithoutInjectableRule
Rule ID: angular:service-without-injectable
Severity: High
Detects services missing @Injectable() decorator.
ComponentWithoutSelectorRule
Rule ID: angular:component-without-selector
Severity: Medium
Detects components missing selector property.
DirectiveWithoutSelectorRule
Rule ID: angular:directive-without-selector
Severity: Medium
Detects directives missing selector property.
PipeWithoutNameRule
Rule ID: angular:pipe-without-name
Severity: Medium
Detects pipes missing name property.
AsyncPipeMisuseRule
Rule ID: angular:async-pipe-misuse
Severity: Warning
Detects incorrect usage of the async pipe in templates. Two violation types:
double-async-subscription: observable used with| asyncin template AND manually subscribed in a lifecycle hookmultiple-async-subscriptions: same observable used with| asynctwo or more times in the same template
UnnecessaryChangeDetectionRule
Rule ID: angular:unnecessary-change-detection
Severity: Low
Detects components using Default change detection when OnPush would suffice, and components calling detectChanges() without using OnPush strategy.
MissingReturnTypeRule
Rule ID: angular:missing-return-type
Severity: Low
Detects methods in Angular classes that are missing explicit return type annotations.
Before:
@Component({...})
export class UserComponent {
// Missing return type
getUserName() {
return this.user?.name || 'Unknown';
}
}After:
@Component({...})
export class UserComponent {
// Explicit return type
getUserName(): string {
return this.user?.name || 'Unknown';
}
}TooManyInputsRule
Rule ID: angular:too-many-inputs
Severity: Low
Detects components with excessive @Input() properties (default threshold: 10).
Configuration:
{
"too-many-inputs": {
"maxInputs": 5
}
}LifecycleHookRule
Rule ID: angular:lifecycle-hook-violation
Severity: Warning/Error
Detects improper lifecycle hook usage and missing cleanup. Also detects heavy ngOnInit with 5 or more service calls (warning), escalating to error at 8 or more.
Before:
@Component({...})
export class TimerComponent {
private intervalId: number;
ngOnInit() {
// Missing cleanup - interval continues after component destruction
this.intervalId = window.setInterval(() => {
console.log('tick');
}, 1000);
}
}After:
@Component({...})
export class TimerComponent implements OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
interval(1000).pipe(
takeUntil(this.destroy$)
).subscribe(() => console.log('tick'));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}TemplateComplexityRule
Rule ID: angular:template-complexity
Severity: Medium
Detects complex operations in templates that cause side effects on every change detection cycle. Also detects component getters with 3 or more non-trivial statements (complex-template-getter violation type).
Before:
@Component({
template: `
<div>{{ getExpensiveComputation() }}</div>
<button (click)="users.push(newUser)">Add User</button>
`,
})
export class UserListComponent {
getExpensiveComputation() {
return this.users.filter((u) => u.active).length * 42;
}
}After:
@Component({
template: `
<div>{{ activeUserCount }}</div>
<button (click)="addUser()">Add User</button>
`,
})
export class UserListComponent {
activeUserCount = this.users.filter((u) => u.active).length;
addUser() {
this.users.push(this.newUser);
this.activeUserCount = this.users.filter((u) => u.active).length;
}
}ImmutableInputViolationRule
Rule ID: angular:immutable-input-violation
Severity: High
Detects mutation of @Input properties that break OnPush change detection.
Before:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
@Input() user: User;
updateUser() {
this.user.name = 'New Name'; // Mutates input - breaks OnPush
}
}After:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
@Input() user: User;
updateUser() {
this.userChange.emit({ ...this.user, name: 'New Name' });
}
@Output() userChange = new EventEmitter<User>();
}MissingTrackByRule
Rule ID: angular:missing-trackby
Severity: Medium
Detects ngFor directives without trackBy functions.
Before:
@Component({
template: `
<li *ngFor="let user of users">{{ user.name }}</li>
`,
})
export class UserListComponent {
users: User[] = [];
}After:
@Component({
template: `
<li *ngFor="let user of users; trackBy: trackByUserId">{{ user.name }}</li>
`,
})
export class UserListComponent {
users: User[] = [];
trackByUserId(index: number, user: User): string {
return user.id;
}
}PresentationalComponentViolationRule
Rule ID: angular:presentational-component-violation
Severity: Warning
Detects presentational (dumb) components that have taken on responsibilities belonging to container components:
service-dependency: constructor orinject()service dependencysubscription-in-lifecycle: subscriptions withouttakeUntilDestroyedmissing-output: service call without a corresponding@Outputbusiness-logic: keyword prefix combined with cyclomatic complexity above 3complex-state-management: 3 or more non-Input array/Map/Set properties
Configurable dialog class detection via .angular-modernizer.json rules section.
ServiceMutableStateRule
Rule ID: angular:service-mutable-state
Severity: Warning
Detects @Injectable services that mutate public array, Map, or Set fields via push, splice, pop, sort, or reverse.
PromiseVsObservableRule
Rule ID: angular:promise-vs-observable-anti-pattern
Severity: Warning
Detects 5 anti-patterns:
.toPromise()calls- Async component methods
- Promise-wrapping of HttpClient services
- Sequential awaits that could run in parallel
- Manual
detectChanges()calls
ComponentWrapperInstantiationRule
Rule ID: angular:component-wrapper-instantiation
Severity: Warning
Detects direct new X() instantiation of wrapper or adapter classes that should be injected. Configurable class name patterns via .angular-modernizer.json.
DtoDirectInstantiationRule
Rule ID: angular:dto-direct-instantiation
Severity: Warning
Detects direct new X() instantiation of DTO, Response, or Result classes. Configurable via .angular-modernizer.json.
MagicStringSelectorRule
Rule ID: angular:magic-string-selector
Severity: Warning
Detects string-literal selector arguments passed to dynamic component loading methods. Target method patterns are configurable via .angular-modernizer.json.
Transformation Rules
ConstructorToInjectRule
Transformation Type: constructor-to-inject
Converts Angular constructor injection to the modern inject() function.
Before:
@Component({...})
export class UserComponent {
constructor(
private userService: UserService,
private router: Router,
@Inject(APP_CONFIG) private config: AppConfig
) {}
}After:
@Component({...})
export class UserComponent {
private userService = inject(UserService);
private router = inject(Router);
private config = inject(APP_CONFIG);
}Features:
- Converts all constructor parameters to
inject()calls - Handles
@Inject()decorators with string tokens - Automatically adds
injectimport from@angular/core - Preserves parameter access modifiers and names
InterfaceExtractionRule
Transformation Type: interface-extraction
Extracts inline type literals into named interfaces.
Before:
@Component({...})
export class UserFormComponent {
@Input() user: {
id: number;
name: string;
email: string;
profile: {
avatar: string;
bio?: string;
};
};
}After:
interface UserProfile {
avatar: string;
bio?: string;
}
interface User {
id: number;
name: string;
email: string;
profile: UserProfile;
}
@Component({...})
export class UserFormComponent {
@Input() user: User;
}Features:
- Extracts complex inline type literals
- Handles nested object types and arrays
- Supports optional properties and union types
- Generates unique interface names to avoid conflicts
DependencyInjectionMigrationRule
Transformation Type: dependency-injection-migration
Modernizes legacy Angular dependency injection patterns including string tokens and ReflectiveInjector.
Before:
// String token injection
@Injectable()
export class ApiService {
constructor(@Inject('API_URL') private apiUrl: string) {}
}
// ReflectiveInjector usage
const injector = ReflectiveInjector.resolveAndCreate([
{ provide: 'API_CONFIG', useValue: config },
]);After:
// Modern injection token
export const API_URL = new InjectionToken<string>('API_URL');
@Injectable()
export class ApiService {
private apiUrl = inject(API_URL);
}
// Modern injector usage
const injector = Injector.create([{ provide: API_CONFIG, useValue: config }]);ConstructorInjectionTransformRule
Transformation Type: constructor-injection-transform
Converts manual service instantiation (new Service()) to proper inject(Service) calls.
Before:
@Component({...})
export class UserComponent {
constructor() {
this.httpClient = new HttpClient();
this.userService = new UserService();
}
private httpClient: HttpClient;
private userService: UserService;
}After:
@Component({...})
export class UserComponent {
private httpClient = inject(HttpClient);
private userService = inject(UserService);
}Configuration:
{
"constructor-injection-transform": {
"enabled": true,
"serviceSuffixes": ["Service", "Repository", "Client", "Store"],
"handleComplexExpressions": true
}
}ComponentInputsInterfaceExtractionRule
Transformation Type: component-inputs-interface-extraction
Extracts component input properties into dedicated interfaces.
Before:
@Component({...})
export class UserCardComponent {
@Input() userId: number;
@Input() userName: string;
@Input() isActive: boolean;
@Input() avatarUrl?: string;
}After:
interface UserCardInputs {
userId: number;
userName: string;
isActive: boolean;
avatarUrl?: string;
}
@Component({...})
export class UserCardComponent {
@Input() inputs: UserCardInputs;
}Configuration:
{
"component-inputs-interface-extraction": {
"enabled": true,
"minInputsThreshold": 3,
"generateUniqueNames": true,
"addToSeparateFile": false
}
}ServiceInjectionCleanupRule
Transformation Type: service-injection-cleanup
Converts manual service instantiation in property initializers and constructors to inject() calls.
Before:
@Component({...})
export class DataComponent {
private http = new HttpClient();
private router: Router = new Router();
constructor() {
this.http = new HttpClient();
}
}After:
@Component({...})
export class DataComponent {
private http = inject(HttpClient);
private router = inject(Router);
}Configuration:
{
"service-injection-cleanup": {
"enabled": true,
"servicePatterns": ["Service$", "Client$", "Repository$"],
"handleComplexExpressions": true
}
}Additional Transformation Types
The plugin also exposes the following transformation types via the transform-code MCP tool:
promise-service-to-observable- Converts Promise-based service methods to Observable equivalentspromise-component-to-reactive- Converts async component methods to reactive patternspromise-cleanup- Removes.toPromise()and related promise bridge codesequential-await-to-forkjoin- Converts independent sequential awaits to parallelforkJoin()missing-output-transform- Addsoutput()signals to presentational components that call services directlysubscription-transform- Converts raw.subscribe()in lifecycle hooks totakeUntilDestroyed()patternlibrary-extraction- Executes a 7-phase library extraction workflow (use afterextract-librariesdetection)static-class-transform- Converts static utility classes to standalone functions
Usage
With MCP Tools
# Start MCP server
pnpm run start:mcp
# Transform constructor injection
transform-code --filePath src/app/user.component.ts --transformation constructor-to-inject
# Extract interfaces
transform-code --filePath src/app/data.models.ts --transformation interface-extraction
# Migrate DI patterns
transform-code --filePath src/app/legacy.service.ts --transformation dependency-injection-migration
# Convert subscriptions to takeUntilDestroyed
transform-code --filePath src/app/user.component.ts --transformation subscription-transform
# Parallelize sequential awaits
transform-code --filePath src/app/data.service.ts --transformation sequential-await-to-forkjoinWith Direct API
import { Kernel } from '@angular-modernizer/core';
import { AngularPlugin } from '@angular-modernizer/plugin-angular';
import { ContextFactory } from '@angular-modernizer/plugin-system';
const kernel = new Kernel({
plugins: [new AngularPlugin()],
});
await kernel.initialize();
const plugin = kernel.getPlugin('@angular-modernizer/plugin-angular');
const transformRules = plugin.getTransformRules();
// Get specific transformation rule
const constructorToInjectRule = transformRules.find(
(rule) => rule.id === 'angular:constructor-to-inject',
);
// Transform a file
const sourceFile = kernel.getProject().addSourceFileAtPath('user.component.ts');
const context = ContextFactory.createTransformContext({
sourceFile,
project: kernel.getProject(),
api: createPublicApi(),
config: {},
});
const result = await constructorToInjectRule.transform(context);
if (result.modified) {
await sourceFile.save();
}Testing
# Run all plugin tests (analysis + transformation)
pnpm test
# Run transformation rule tests specifically
pnpm test transform-rules/
# Run with coverage
pnpm test:coveragePerformance
Tested on production Angular codebases:
| Metric | Analysis | Transformation | |-|-|-| | Files Processed | 2,753 | 500+ | | Execution Time | ~3.2s | ~1.5s | | Memory Usage | ~185 MB | ~95 MB |
Contributing
See DEVELOPMENT_GUIDE.md for architecture details and TESTING_GUIDE.md for testing conventions.
License
MIT
