Skip to content

Commit 115f7a8

Browse files
committed
refactor(@angular/cli): Make modernize use the file system
- Refactors the `modernize` tool to run all schematics on a temporary directory on the file system instead of using the `SchematicTestRunner` and a virtual file system. - This fixes issues with migrations that require access to the real file system, such as the `control-flow-migration`. - Extracts the core logic into a separate `runModernization` function to improve testability. - Adds a new test suite (`modernize_spec.ts`) to cover the refactored logic, including tests for default transformations, file system setup, and updated file content.
1 parent 262c006 commit 115f7a8

5 files changed

Lines changed: 341 additions & 130 deletions

File tree

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,16 @@
124124
"karma-source-map-support": "1.4.0",
125125
"listr2": "9.0.1",
126126
"lodash": "^4.17.21",
127-
"npm": "^11.0.0",
128127
"magic-string": "0.30.17",
129-
"rollup-plugin-dts": "6.2.1",
130-
"rollup-plugin-sourcemaps2": "0.5.3",
128+
"npm": "^11.0.0",
131129
"prettier": "^3.0.0",
132130
"protractor": "~7.0.0",
133131
"puppeteer": "18.2.1",
134132
"quicktype-core": "23.2.6",
135133
"rollup": "4.46.1",
136134
"rollup-license-plugin": "~3.0.1",
135+
"rollup-plugin-dts": "6.2.1",
136+
"rollup-plugin-sourcemaps2": "0.5.3",
137137
"semver": "7.7.2",
138138
"shelljs": "^0.10.0",
139139
"source-map-support": "0.5.21",
@@ -174,5 +174,8 @@
174174
},
175175
"resolutions": {
176176
"typescript": "5.9.1-rc"
177+
},
178+
"dependencies": {
179+
"rxjs": "7.8.2"
177180
}
178181
}

packages/angular/cli/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,12 @@ ts_project(
125125
":node_modules/@angular-devkit/core",
126126
":node_modules/@angular-devkit/schematics",
127127
":node_modules/yargs",
128+
"//:node_modules/@types/jasmine",
128129
"//:node_modules/@types/semver",
129130
"//:node_modules/@types/yargs",
131+
"//:node_modules/rxjs",
130132
"//:node_modules/semver",
133+
"//packages/angular_devkit/schematics/testing",
131134
],
132135
)
133136

Lines changed: 179 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2-
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
3-
import { Tree } from '@angular-devkit/schematics';
42
import { z } from 'zod';
53
import path from 'node:path';
4+
import fs from 'node:fs';
5+
import os from 'node:os';
6+
import { NodeWorkflow } from '@angular-devkit/schematics/tools';
7+
import { NodeJsSyncHost } from '@angular-devkit/core/node';
8+
import { virtualFs } from '@angular-devkit/core';
69

7-
export const SCHEMATICS_ROOT = '../../../../../../node_modules/@angular/core/schematics';
10+
export const SCHEMATICS_ROOT = '../../../../../../@angular/core/schematics';
811

912
enum SchematicTarget {
1013
Code,
@@ -96,6 +99,165 @@ const TRANSFORMATIONS = [
9699
] as const;
97100

98101
const ALL_TRANSFORMATIONS = TRANSFORMATIONS.map((t) => t.name);
102+
103+
const modernizeInputSchema = z.object({
104+
files: z.array(
105+
z.object({
106+
name: z.string().describe('The name of the file.'),
107+
content: z.string().describe('The content of the file.'),
108+
}),
109+
),
110+
transformations: z.array(z.enum(ALL_TRANSFORMATIONS as [string, ...string[]])).optional(),
111+
mode: z
112+
.enum(['convert-to-standalone', 'prune-ng-modules', 'standalone-bootstrap'])
113+
.optional()
114+
.describe('The mode to use for the standalone transformation.'),
115+
});
116+
117+
export type ModernizeInput = z.infer<typeof modernizeInputSchema>;
118+
119+
// Extracted logic for testability
120+
export async function runModernization(
121+
input: ModernizeInput,
122+
workflow?: NodeWorkflow,
123+
tempDir?: string,
124+
) {
125+
const ownTempDir = !tempDir;
126+
tempDir ??= fs.mkdtempSync(path.join(os.tmpdir(), 'angular-cli-modernize-'));
127+
try {
128+
const fileNames = input.files.map((f) => f.name);
129+
const tsconfig = {
130+
compilerOptions: {
131+
target: 'es2022',
132+
module: 'esnext',
133+
lib: ['es2022', 'dom'],
134+
skipLibCheck: true,
135+
esModuleInterop: true,
136+
allowSyntheticDefaultImports: true,
137+
experimentalDecorators: true,
138+
emitDecoratorMetadata: true,
139+
useDefineForClassFields: false,
140+
},
141+
files: fileNames,
142+
};
143+
fs.writeFileSync(path.join(tempDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2));
144+
for (const file of input.files) {
145+
const filePath = path.join(tempDir, file.name);
146+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
147+
fs.writeFileSync(filePath, file.content);
148+
}
149+
150+
workflow ??= new NodeWorkflow(
151+
new virtualFs.ScopedHost(new NodeJsSyncHost(), path.normalize(tempDir) as any),
152+
{
153+
packageManager: 'pnpm',
154+
dryRun: false,
155+
},
156+
);
157+
158+
const collectionPaths = {
159+
[SchematicRunner.Migration]: path.join(__dirname, SCHEMATICS_ROOT, 'migrations.json'),
160+
[SchematicRunner.Collection]: path.join(__dirname, SCHEMATICS_ROOT, 'collection.json'),
161+
};
162+
163+
const transformationsToRun =
164+
input.transformations && input.transformations.length > 0
165+
? TRANSFORMATIONS.filter((t) => input.transformations!.includes(t.name))
166+
: TRANSFORMATIONS.filter((t) => t.includedByDefault);
167+
168+
const hasCodeFiles = input.files.some((f) => f.name.endsWith('.ts'));
169+
const hasTemplateFiles = input.files.some(
170+
(f) => f.name.endsWith('.html') || f.name.endsWith('.ng.html'),
171+
);
172+
173+
let instructions: string | undefined;
174+
const documentation = new Set<string>();
175+
176+
for (const transformation of transformationsToRun) {
177+
if (transformation.documentation) {
178+
documentation.add(transformation.documentation);
179+
}
180+
181+
let options: { [key: string]: unknown } = { path: tempDir };
182+
if (transformation.name === 'standalone') {
183+
const mode = input.mode ?? 'convert-to-standalone';
184+
options = { ...options, mode };
185+
186+
if (mode === 'convert-to-standalone') {
187+
instructions =
188+
'The first step of the `standalone` migration has been performed. Please verify that your application builds and runs correctly. Then, run this tool again with `mode: "prune-ng-modules"` to continue.';
189+
} else if (mode === 'prune-ng-modules') {
190+
instructions =
191+
'The second step of the `standalone` migration has been performed. Please verify that your application builds and runs correctly. Then, run this tool again with `mode: "standalone-bootstrap"` to complete the migration.';
192+
} else {
193+
instructions = 'The `standalone` migration has been completed.';
194+
}
195+
} else if (transformation.name === 'zoneless') {
196+
instructions =
197+
'The `zoneless` migration is a manual process. Please follow the instructions at https://angular.dev/guide/zoneless to complete the migration.';
198+
continue; // Don't run a schematic for zoneless
199+
}
200+
201+
if (
202+
(transformation.target === SchematicTarget.Code && !hasCodeFiles) ||
203+
(transformation.target === SchematicTarget.Template && !hasTemplateFiles)
204+
) {
205+
continue;
206+
}
207+
208+
await workflow
209+
.execute({
210+
collection: collectionPaths[transformation.runner],
211+
schematic: transformation.name,
212+
options,
213+
})
214+
.toPromise();
215+
}
216+
217+
const updatedFiles = input.files.map((file) => {
218+
const filePath = path.join(tempDir!, file.name);
219+
if (!fs.existsSync(filePath)) {
220+
return { name: file.name, content: undefined, changed: true };
221+
}
222+
const updatedContent = fs.readFileSync(filePath, 'utf8');
223+
const changed = updatedContent !== file.content;
224+
225+
return {
226+
name: file.name,
227+
content: changed ? updatedContent : undefined,
228+
changed,
229+
};
230+
});
231+
232+
const structuredContent = {
233+
files: updatedFiles,
234+
instructions,
235+
documentation: documentation.size > 0 ? [...documentation].join('\n') : undefined,
236+
};
237+
238+
return {
239+
content: [{ type: 'text' as const, text: JSON.stringify(structuredContent) }],
240+
structuredContent,
241+
};
242+
} catch (e) {
243+
const message = e instanceof Error ? e.message : 'An unknown error occurred.';
244+
return {
245+
content: [
246+
{
247+
type: 'text' as const,
248+
text: `Failed to run modernization migrations: ${message}`,
249+
},
250+
],
251+
structuredContent: {},
252+
isError: true,
253+
};
254+
} finally {
255+
if (ownTempDir && tempDir) {
256+
fs.rmSync(tempDir, { recursive: true, force: true });
257+
}
258+
}
259+
}
260+
99261
export function registerModernizeTool(server: McpServer): void {
100262
server.registerTool(
101263
'modernize',
@@ -122,134 +284,24 @@ export function registerModernizeTool(server: McpServer): void {
122284
.join('\n') +
123285
'/\n<On-Request Transformations>\n' +
124286
'\n</Transformations>\n',
125-
inputSchema: {
126-
files: z.array(
127-
z.object({
128-
name: z.string().describe('The name of the file.'),
129-
content: z.string().describe('The content of the file.'),
130-
}),
131-
),
132-
// Zod's `enum` requires a non-empty array of string literals, but TypeScript
133-
// infers `ALL_TRANSFORMATIONS` as `string[]`. The `as` cast is a
134-
// workaround to satisfy Zod's type checker.
135-
transformations: z
136-
.array(z.enum(ALL_TRANSFORMATIONS as [string, ...string[]]))
137-
.optional(),
138-
mode: z
139-
.enum(['convert-to-standalone', 'prune-ng-modules', 'standalone-bootstrap'])
140-
.optional()
141-
.describe('The mode to use for the standalone transformation.'),
287+
annotations: {
288+
readOnlyHint: true,
142289
},
290+
inputSchema: modernizeInputSchema.shape,
143291
outputSchema: {
144-
files: z.array(
145-
z.object({
146-
name: z.string().describe('The name of the file.'),
147-
content: z.string().optional().describe('The updated content of the file.'),
148-
changed: z.boolean().describe('Whether the file was changed.'),
149-
}),
150-
),
292+
files: z
293+
.array(
294+
z.object({
295+
name: z.string().describe('The name of the file.'),
296+
content: z.string().optional().describe('The updated content of the file.'),
297+
changed: z.boolean().describe('Whether the file was changed.'),
298+
}),
299+
)
300+
.optional(),
151301
instructions: z.string().optional().describe('Additional instructions.'),
152302
documentation: z.string().optional().describe('A link to relevant documentation.'),
153303
},
154-
155-
},
156-
async (input) => {
157-
try {
158-
// We don't have access to the file system to run schematics on it directly. Instead we
159-
// use the test runner to create a virtual filesystem, populate it with the input files,
160-
// and run the schematics on them.
161-
162-
const runners = {
163-
[SchematicRunner.Migration]: new SchematicTestRunner(
164-
'@angular/core',
165-
path.join(__dirname, SCHEMATICS_ROOT, 'migrations.json'),
166-
),
167-
[SchematicRunner.Collection]: new SchematicTestRunner(
168-
'@angular/core',
169-
path.join(__dirname, SCHEMATICS_ROOT, 'collection.json'),
170-
),
171-
};
172-
173-
const transformationsToRun =
174-
input.transformations && input.transformations.length > 0
175-
? TRANSFORMATIONS.filter((t) => input.transformations!.includes(t.name))
176-
: TRANSFORMATIONS.filter((t) => t.includedByDefault);
177-
178-
const hasCodeFiles = input.files.some((f) => f.name.endsWith('.ts'));
179-
const hasTemplateFiles = input.files.some(
180-
(f) => f.name.endsWith('.html') || f.name.endsWith('.ng.html'),
181-
);
182-
183-
let tree: Tree = new UnitTestTree(Tree.empty());
184-
for (const file of input.files) {
185-
tree.create(file.name, file.content);
186-
}
187-
188-
let instructions: string | undefined;
189-
const documentation = new Set<string>();
190-
191-
for (const transformation of transformationsToRun) {
192-
if (transformation.documentation) {
193-
documentation.add(transformation.documentation);
194-
}
195-
if (transformation.name === 'standalone') {
196-
const mode = input.mode ?? 'convert-to-standalone';
197-
tree = await runners[transformation.runner].runSchematic('standalone', { mode }, tree);
198-
199-
if (mode === 'convert-to-standalone') {
200-
instructions =
201-
'The first step of the `standalone` migration has been performed. Please verify that your application builds and runs correctly. Then, run this tool again with `mode: "prune-ng-modules"` to continue.';
202-
} else if (mode === 'prune-ng-modules') {
203-
instructions =
204-
'The second step of the `standalone` migration has been performed. Please verify that your application builds and runs correctly. Then, run this tool again with `mode: "standalone-bootstrap"` to complete the migration.';
205-
} else {
206-
instructions = 'The `standalone` migration has been completed.';
207-
}
208-
} else if (transformation.name === 'zoneless') {
209-
instructions =
210-
'The `zoneless` migration is a manual process. Please follow the instructions at https://angular.dev/guide/zoneless to complete the migration.';
211-
} else if (transformation.target === SchematicTarget.Code && hasCodeFiles) {
212-
tree = await runners[transformation.runner].runSchematic(transformation.name, {}, tree);
213-
} else if (transformation.target === SchematicTarget.Template && hasTemplateFiles) {
214-
tree = await runners[transformation.runner].runSchematic(transformation.name, {}, tree);
215-
}
216-
}
217-
218-
const updatedFiles = input.files.map((file) => {
219-
const updatedContent = tree.read(file.name)?.toString();
220-
const changed = updatedContent !== undefined && updatedContent !== file.content;
221-
222-
return {
223-
name: file.name,
224-
content: changed ? updatedContent : undefined,
225-
changed,
226-
};
227-
});
228-
229-
return {
230-
content: [
231-
{
232-
type: 'text' as const,
233-
text: 'Modernization migrations applied successfully.',
234-
},
235-
],
236-
structuredContent: {
237-
files: updatedFiles,
238-
instructions,
239-
documentation: documentation.size > 0 ? [...documentation].join('\n') : undefined,
240-
},
241-
};
242-
} catch (e) {
243-
const message = e instanceof Error ? e.message : 'An unknown error occurred.';
244-
return {
245-
content: [
246-
{
247-
type: 'text' as const,
248-
text: `Failed to run modernization migrations: ${message}`,
249-
},
250-
],
251-
};
252-
}
253304
},
305+
(input) => runModernization(input as ModernizeInput),
254306
);
255-
}
307+
}

0 commit comments

Comments
 (0)