Plugin contracts and context-based dependency injection for the Angular Modernization Platform.
The @angular-modernizer/plugin-system package defines the interfaces and context types that enable the API-driven plugin architecture. It provides the context objects and dependency injection patterns that keep plugins stateless and composable.
The package contains interfaces plus one class, ContextFactory. Its only runtime dependency is ts-morph (types of SourceFile and Project).
The plugin system uses a context-driven pattern where all dependencies are passed through context objects rather than constructor injection:
// Constructor injection - tight coupling, avoid this
class OldStylePlugin {
constructor(
private api: PublicApi,
private project: Project,
) {}
analyze() {
/* uses this.api, this.project */
}
}
// Context injection - loose coupling, use this
class CorrectPlugin {
constructor() {} // Stateless
analyze(context: AnalysisContext) {
const { api, project, sourceFile } = context;
}
}
The contract all plugins must implement:
interface Plugin {
readonly name: string;
readonly version: string;
readonly description?: string;
readonly author?: string;
initialize(kernel: IKernel): Promise<void>;
getAnalysisRules(): AnalysisRule[];
getTransformRules(): TransformRule[];
getValidationRules(): ValidationRule[];
}
IKernel is intentionally minimal: it only exposes getProjectRoot(). Plugins that need project-level access must defer that work until analyze() or transform() is called, where they receive the full context.
Analyzes a file and returns one result per finding.
interface AnalysisRule {
readonly id: string;
readonly name: string;
readonly description: string;
readonly severity: 'error' | 'warning' | 'info';
readonly category: string;
readonly tags?: string[];
analyze(context: AnalysisContext): Promise<AnalysisResult[]>;
}
interface AnalysisResult {
ruleId: string;
message: string;
filePath: string;
line?: number;
column?: number;
suggestedFix?: string;
severity?: 'error' | 'warning' | 'info';
metadata?: Record<string, unknown>;
}
Modifies code automatically. Implementations must be idempotent, atomic, and reversible.
interface TransformRule {
readonly id: string;
readonly name: string;
readonly description: string;
readonly category: string;
readonly tags?: string[];
transform(context: TransformContext): Promise<TransformResult>;
}
interface TransformResult {
ruleId: string;
modified: boolean;
message: string;
filePath: string;
changeCount?: number;
metadata?: Record<string, unknown>;
}
Validates transformations and code quality.
interface ValidationRule {
readonly id: string;
readonly name: string;
readonly description: string;
readonly category: string;
validate(context: ValidationContext): Promise<ValidationResult>;
}
interface ValidationResult {
ruleId: string;
passed: boolean;
message: string;
filePath: string;
details?: string;
metadata?: Record<string, unknown>;
}
Optional hooks: onBeforeAnalysis, onAfterAnalysis, onBeforeTransform, onAfterTransform, onShutdown (all () => Promise<void>).
Context objects provide scoped access to dependencies. Always construct them via ContextFactory, never by hand. All contexts share BaseContext; api is typed by a generic parameter TApi (pass PublicApi from @angular-modernizer/api).
interface BaseContext<TApi> {
readonly sourceFile: SourceFile;
readonly project: Project;
readonly api: TApi;
readonly config: Record<string, unknown>;
readonly filePath: string;
}
Read-only access for analysis operations.
interface AnalysisContext<TApi> extends BaseContext<TApi> {
readonly type: 'analysis';
readonly options?: { deep?: boolean; maxDepth?: number };
}
Read-write access for transformation operations.
interface TransformContext<TApi> extends BaseContext<TApi> {
readonly type: 'transform';
readonly options?: { dryRun?: boolean; autoSave?: boolean; format?: boolean };
}
Access for validation operations after transformation.
interface ValidationContext<TApi> extends BaseContext<TApi> {
readonly type: 'validation';
readonly options?: { failFast?: boolean };
}
The ContextFactory creates properly typed context objects and fills type, filePath (from sourceFile) and config (default {}). Always use it; never construct context objects directly.
class ContextFactory {
static createAnalysisContext<TApi>(params: {
sourceFile: SourceFile;
project: Project;
api: TApi;
config?: Record<string, unknown>;
options?: AnalysisContext<TApi>['options'];
}): AnalysisContext<TApi>;
static createTransformContext<TApi>(params: { /* same, with TransformContext options */ }): TransformContext<TApi>;
static createValidationContext<TApi>(params: { /* same, with ValidationContext options */ }): ValidationContext<TApi>;
}
AnalysisContext requires type, sourceFile, project, api, config and filePath. Tests that pass only { sourceFile } may compile under ts-jest's lenient inline tsconfig but will fail in pnpm run type-check.
import {
type Plugin,
type IKernel,
type AnalysisRule,
type TransformRule,
type ValidationRule,
} from '@angular-modernizer/plugin-system';
class MyPlugin implements Plugin {
readonly name = '@my-org/plugin-custom';
readonly version = '1.0.0';
readonly description = 'Custom analysis and transformation rules';
async initialize(kernel: IKernel): Promise<void> {
// Optional initialization
}
getAnalysisRules(): AnalysisRule[] {
return [new MyAnalysisRule()];
}
getTransformRules(): TransformRule[] {
return [new MyTransformRule()];
}
getValidationRules(): ValidationRule[] {
return [];
}
}
import { Node } from 'ts-morph';
import {
type AnalysisRule,
type AnalysisContext,
type AnalysisResult,
} from '@angular-modernizer/plugin-system';
class DirectInstantiationRule implements AnalysisRule {
readonly id = 'my-plugin:direct-instantiation';
readonly name = 'Direct Service Instantiation';
readonly description = 'Detects direct instantiation of services (DIP violation)';
readonly severity = 'warning' as const;
readonly category = 'solid';
async analyze(context: AnalysisContext): Promise<AnalysisResult[]> {
const { sourceFile, filePath } = context;
const results: AnalysisResult[] = [];
sourceFile.forEachDescendant((node) => {
if (!Node.isNewExpression(node)) return;
const className = node.getExpression().getText();
if (!className.endsWith('Service')) return;
results.push({
ruleId: this.id,
message: `Direct instantiation of ${className} violates Dependency Inversion Principle`,
filePath,
line: node.getStartLineNumber(),
severity: this.severity,
});
});
return results;
}
}
import {
type TransformRule,
type TransformContext,
type TransformResult,
} from '@angular-modernizer/plugin-system';
class AddInjectImportRule implements TransformRule {
readonly id = 'my-plugin:add-inject-import';
readonly name = 'Add inject import';
readonly description = 'Adds the inject import from @angular/core';
readonly category = 'angular';
async transform(context: TransformContext): Promise<TransformResult> {
const { sourceFile, filePath, api } = context;
if (api.transformation.importManager.hasImport(sourceFile, 'inject', '@angular/core')) {
return { ruleId: this.id, modified: false, message: 'Import already present', filePath };
}
api.transformation.importManager.addNamedImport(sourceFile, '@angular/core', 'inject');
return { ruleId: this.id, modified: true, message: 'Added inject import', filePath, changeCount: 1 };
}
}
import { ContextFactory } from '@angular-modernizer/plugin-system';
import { createPublicApi } from '@angular-modernizer/api';
const analysisContext = ContextFactory.createAnalysisContext({
sourceFile,
project,
api: createPublicApi(project),
config: {
solid: { godSwitch: { maxCases: 10 } },
},
});
const transformContext = ContextFactory.createTransformContext({
sourceFile,
project,
api: createPublicApi(project),
options: { dryRun: true },
});
All dependencies are injected via context objects:
// Correct: context injection
class MyRule implements AnalysisRule {
analyze(context: AnalysisContext) {
const { api, project, sourceFile } = context;
}
}
// Wrong: constructor injection
class MyRule implements AnalysisRule {
constructor(private api: PublicApi) {}
analyze(context: AnalysisContext) { /* uses this.api */ }
}
Rules must not hold internal state between invocations:
// Correct: stateless
class MyRule implements AnalysisRule {
analyze(context: AnalysisContext): Promise<AnalysisResult[]> {
// Same context always produces same result
}
}
// Wrong: stateful
class MyRule implements AnalysisRule {
private analyzedFiles = new Set<string>(); // Do not do this
}
Context objects must not be mutated:
// Correct
function analyze(context: AnalysisContext) {
const { sourceFile, api } = context;
}
// Wrong
function analyze(context: AnalysisContext) {
context.config.additionalSetting = true; // Do not do this
}
Rule IDs must follow plugin-name:rule-name format (e.g., angular:component-wrapper-instantiation, solid:srp-single-responsibility-violation). The prefix must match the plugin's registered name.
Plugins must not manipulate the ts-morph AST directly. All analysis and transformation must go through context.api. Direct sourceFile.getClasses() calls bypass the API's caching and abstraction layers.
packages/plugin-system/
src/
plugin-interface.ts # IKernel, Plugin, rule and result interfaces, PluginLifecycle
contexts.ts # BaseContext, AnalysisContext, TransformContext, ValidationContext, ContextFactory
index.ts
package.json
tsconfig.json
jest.config.js
README.md
The package has no tests of its own (jest --passWithNoTests). Rule tests in the plugin packages build contexts with ContextFactory:
import { ContextFactory } from '@angular-modernizer/plugin-system';
import { createPublicApi } from '@angular-modernizer/api';
import { Project } from 'ts-morph';
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('/test/user.service.ts', 'export class UserService {}');
const context = ContextFactory.createAnalysisContext({
sourceFile,
project,
api: createPublicApi(project),
});
const results = await new MyAnalysisRule().analyze(context);
solid:dip-violation-direct-instantiation, not rule1suggestedFix when possibleanalyze() or transform()const { api, sourceFile } = context;configPlugin interface: getNewRuleType(): NewRuleType[]NewRuleContextContextFactory.createNewRuleContext()
Plugin contracts for the Angular Modernizer.
Defines what a plugin is (Plugin), the three rule kinds it can contribute (AnalysisRule, TransformRule, ValidationRule) and the context objects the kernel passes to them (AnalysisContext, TransformContext, ValidationContext). ContextFactory builds those contexts. IKernel is the minimal kernel view a plugin sees during Plugin.initialize.
Plugin authors import only from
@angular-modernizer/plugin-system.