Skip to content

refactor(@angular/cli): add a get Zoneless/OnPush MCP tool #30868

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/angular/cli/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ ts_project(
"//:node_modules/@types/semver",
"//:node_modules/@types/yargs",
"//:node_modules/@types/yarnpkg__lockfile",
"//:node_modules/fast-glob",
"//:node_modules/listr2",
"//:node_modules/semver",
"//:node_modules/typescript",
],
)

Expand Down Expand Up @@ -127,6 +129,7 @@ ts_project(
":node_modules/yargs",
"//:node_modules/@types/semver",
"//:node_modules/@types/yargs",
"//:node_modules/fast-glob",
"//:node_modules/semver",
],
)
Expand Down
3 changes: 2 additions & 1 deletion packages/angular/cli/src/commands/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { BEST_PRACTICES_TOOL } from './tools/best-practices';
import { DOC_SEARCH_TOOL } from './tools/doc-search';
import { FIND_EXAMPLE_TOOL } from './tools/examples';
import { MODERNIZE_TOOL } from './tools/modernize';
import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration';
import { LIST_PROJECTS_TOOL } from './tools/projects';
import { AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';

Expand All @@ -28,7 +29,7 @@ const STABLE_TOOLS = [BEST_PRACTICES_TOOL, DOC_SEARCH_TOOL, LIST_PROJECTS_TOOL]
* The set of tools that are available but not enabled by default.
* These tools are considered experimental and may have limitations.
*/
const EXPERIMENTAL_TOOLS = [FIND_EXAMPLE_TOOL, MODERNIZE_TOOL] as const;
const EXPERIMENTAL_TOOLS = [FIND_EXAMPLE_TOOL, MODERNIZE_TOOL, ZONELESS_MIGRATION_TOOL] as const;

export async function createMcpServer(
options: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import ts from 'typescript';
import { createUnsupportedZoneUsagesMessage } from './prompts';
import { getImportSpecifier } from './ts_utils';
import { MigrationResponse } from './types';

export function analyzeForUnsupportedZoneUses(sourceFile: ts.SourceFile): MigrationResponse | null {
const ngZoneImport = getImportSpecifier(sourceFile, '@angular/core', 'NgZone');
if (!ngZoneImport) {
return null;
}
const unsupportedUsages = findUnsupportedZoneUsages(sourceFile, ngZoneImport);

if (unsupportedUsages.length === 0) {
return null;
}

const locations = unsupportedUsages.map((node: ts.Node) => {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());

return `line ${line + 1}, character ${character + 1}: ${node.getText()}`;
});

return createUnsupportedZoneUsagesMessage(locations, sourceFile.fileName);
}

/**
* Finds usages of `NgZone` that are not supported in zoneless applications.
* @param sourceFile The source file to check.
* @param ngZoneImport The import specifier for `NgZone`.
* @returns A list of nodes that are unsupported `NgZone` usages.
*/
export function findUnsupportedZoneUsages(
sourceFile: ts.SourceFile,
ngZoneImport: ts.ImportSpecifier,
): ts.Node[] {
const unsupportedUsages: ts.Node[] = [];
const ngZoneClassName = ngZoneImport.name.text;

const staticMethods = new Set([
'isInAngularZone',
'assertInAngularZone',
'assertNotInAngularZone',
]);
const instanceMethods = new Set(['onMicrotaskEmpty', 'onStable']);

ts.forEachChild(sourceFile, function visit(node) {
if (ts.isPropertyAccessExpression(node)) {
const propertyName = node.name.text;
const expressionText = node.expression.getText(sourceFile);

// Static: NgZone.method()
if (expressionText === ngZoneClassName && staticMethods.has(propertyName)) {
unsupportedUsages.push(node);
}

// Instance: zone.method() or this.zone.method()
if (instanceMethods.has(propertyName)) {
unsupportedUsages.push(node);
}
}
ts.forEachChild(node, visit);
});

return unsupportedUsages;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol';
import { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types';
import ts from 'typescript';
import { analyzeForUnsupportedZoneUses } from './analyze_for_unsupported_zone_uses';
import { migrateTestFile } from './migrate_test_file';
import { generateZonelessMigrationInstructionsForComponent } from './prompts';
import { sendDebugMessage } from './send_debug_message';
import { getImportSpecifier } from './ts_utils';
import { MigrationResponse } from './types';

export async function migrateSingleFile(
sourceFile: ts.SourceFile,
extras: RequestHandlerExtra<ServerRequest, ServerNotification>,
): Promise<MigrationResponse | null> {
const testBedSpecifier = getImportSpecifier(sourceFile, '@angular/core/testing', 'TestBed');
const isTestFile = sourceFile.fileName.endsWith('.spec.ts') || !!testBedSpecifier;
if (isTestFile) {
return migrateTestFile(sourceFile);
}

const unsupportedZoneUseResponse = analyzeForUnsupportedZoneUses(sourceFile);
if (unsupportedZoneUseResponse) {
return unsupportedZoneUseResponse;
}

let detectedStrategy: 'OnPush' | 'Default' | undefined;
let hasComponentDecorator = false;

const componentSpecifier = getImportSpecifier(sourceFile, '@angular/core', 'Component');
if (!componentSpecifier) {
sendDebugMessage(`No component decorator found in file: ${sourceFile.fileName}`, extras);

return null;
}

ts.forEachChild(sourceFile, function visit(node) {
if (detectedStrategy) {
return; // Already found, no need to traverse further
}

if (ts.isDecorator(node) && ts.isCallExpression(node.expression)) {
const callExpr = node.expression;
if (callExpr.expression.getText(sourceFile) === 'Component') {
hasComponentDecorator = true;
if (callExpr.arguments.length > 0 && ts.isObjectLiteralExpression(callExpr.arguments[0])) {
const componentMetadata = callExpr.arguments[0];
for (const prop of componentMetadata.properties) {
if (
ts.isPropertyAssignment(prop) &&
prop.name.getText(sourceFile) === 'changeDetection'
) {
if (
ts.isPropertyAccessExpression(prop.initializer) &&
prop.initializer.expression.getText(sourceFile) === 'ChangeDetectionStrategy'
) {
const strategy = prop.initializer.name.text;
if (strategy === 'OnPush' || strategy === 'Default') {
detectedStrategy = strategy;

return;
}
}
}
}
}
}
}
ts.forEachChild(node, visit);
});

if (
!hasComponentDecorator ||
// component uses OnPush. We don't have anything more to do here.
detectedStrategy === 'OnPush' ||
// Explicit default strategy, assume there's a reason for it (already migrated, or is a library that hosts Default components) and skip.
detectedStrategy === 'Default'
) {
sendDebugMessage(
`Component decorator found with strategy: ${detectedStrategy} in file: ${sourceFile.fileName}. Skipping migration for file.`,
extras,
);

return null;
}

// Component decorator found, but no change detection strategy.
return generateZonelessMigrationInstructionsForComponent(sourceFile.fileName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol';
import { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types';
import ts from 'typescript';
import { migrateSingleFile } from './migrate_single_file';

const fakeExtras = {
sendDebugMessage: jasmine.createSpy(),
sendNotification: jasmine.createSpy(),
} as unknown as RequestHandlerExtra<ServerRequest, ServerNotification>;

describe('migrateSingleFile', () => {
it('should identify test files by extension', async () => {
const fileName = 'test.spec.ts';
const sourceFile = ts.createSourceFile(fileName, '', ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);

expect(result?.content[0].text).toContain(
'The test file `test.spec.ts` is not yet configured for zoneless change detection.' +
' You need to enable it for the entire test suite and then identify which specific tests fail.',
);
});

it('should identify test files by TestBed import', async () => {
const fileName = 'test.ts';
const content = `import { TestBed } from '@angular/core/testing';`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);

expect(result?.content[0].text).toContain(
'The test file `test.ts` is not yet configured for zoneless change detection.' +
' You need to enable it for the entire test suite and then identify which specific tests fail.',
);
});

it('should return unsupported zone usages message if NgZone is used', async () => {
const fileName = 'app.component.ts';
const content = `
import { Component, NgZone } from '@angular/core';

@Component({
selector: 'app-root',
template: 'Hello',
})
export class AppComponent {
constructor(private zone: NgZone) {
this.zone.onMicrotaskEmpty(() => {});
}
}
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);

expect(result?.content[0].text).toContain(
'The component uses NgZone APIs that are incompatible with zoneless applications',
);
});

it('should return null if component already has ChangeDetectionStrategy.OnPush', async () => {
const fileName = 'app.component.ts';
const content = `
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
selector: 'app-root',
template: 'Hello',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {}
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);

expect(result).toBeNull();
});

it('should return null if component has ChangeDetectionStrategy.Default', async () => {
const fileName = 'app.component.ts';
const content = `
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
selector: 'app-root',
template: 'Hello',
changeDetection: ChangeDetectionStrategy.Default,
})
export class AppComponent {}
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);

expect(result).toBeNull();
});

it('should return migration instructions for a component without a change detection strategy', async () => {
const fileName = 'app.component.ts';
const content = `
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: 'Hello',
})
export class AppComponent {}
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);

expect(result?.content[0].text).toContain(
'The component does not currently use a change detection strategy, which means it may rely on Zone.js',
);
});

it('should return null for a file that is not a component', async () => {
const fileName = 'some.service.ts';
const content = `
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class SomeService {}
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);

expect(result).toBeNull();
});

it('should return null for an empty file', async () => {
const fileName = 'empty.ts';
const content = ``;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);

expect(result).toBeNull();
});
});
Loading
Loading