diff --git a/common/changes/@microsoft/rush/feat-rush-scan-support-folders_2024-12-16-03-01.json b/common/changes/@microsoft/rush/feat-rush-scan-support-folders_2024-12-16-03-01.json new file mode 100644 index 00000000000..a84794650c2 --- /dev/null +++ b/common/changes/@microsoft/rush/feat-rush-scan-support-folders_2024-12-16-03-01.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": " Modify rush scan, support executing projects under rush and custom scanning folders.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/libraries/rush-lib/src/cli/actions/ScanAction.ts b/libraries/rush-lib/src/cli/actions/ScanAction.ts index b99e7da7f63..39bb2f6608f 100644 --- a/libraries/rush-lib/src/cli/actions/ScanAction.ts +++ b/libraries/rush-lib/src/cli/actions/ScanAction.ts @@ -3,14 +3,16 @@ import * as path from 'path'; import builtinPackageNames from 'builtin-modules'; -import { Colorize } from '@rushstack/terminal'; -import type { CommandLineFlagParameter } from '@rushstack/ts-command-line'; -import { FileSystem } from '@rushstack/node-core-library'; +import { Colorize, type ITerminal } from '@rushstack/terminal'; +import type { CommandLineFlagParameter, CommandLineStringListParameter } from '@rushstack/ts-command-line'; +import { FileSystem, FileConstants, JsonFile } from '@rushstack/node-core-library'; +import type FastGlob from 'fast-glob'; import type { RushCommandLineParser } from '../RushCommandLineParser'; import { BaseConfiglessRushAction } from './BaseRushAction'; +import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; -export interface IJsonOutput { +export interface IScanResult { /** * Dependencies scan from source code */ @@ -26,8 +28,11 @@ export interface IJsonOutput { } export class ScanAction extends BaseConfiglessRushAction { + private readonly _terminal: ITerminal; private readonly _jsonFlag: CommandLineFlagParameter; private readonly _allFlag: CommandLineFlagParameter; + private readonly _folders: CommandLineStringListParameter; + private readonly _projects: CommandLineStringListParameter; public constructor(parser: RushCommandLineParser) { super({ @@ -40,7 +45,7 @@ export class ScanAction extends BaseConfiglessRushAction { ` declaring them as dependencies in the package.json file. Such "phantom dependencies"` + ` can cause problems. Rush and PNPM use symlinks specifically to protect against phantom dependencies.` + ` These protections may cause runtime errors for existing projects when they are first migrated into` + - ` a Rush monorepo. The "rush scan" command is a handy tool for fixing these errors. It scans the "./src"` + + ` a Rush monorepo. The "rush scan" command is a handy tool for fixing these errors. It default scans the "./src"` + ` and "./lib" folders for import syntaxes such as "import __ from '__'", "require('__')",` + ` and "System.import('__'). It prints a report of the referenced packages. This heuristic is` + ` not perfect, but it can save a lot of time when migrating projects.`, @@ -56,14 +61,31 @@ export class ScanAction extends BaseConfiglessRushAction { parameterLongName: '--all', description: 'If this flag is specified, output will list all detected dependencies.' }); + this._folders = this.defineStringListParameter({ + parameterLongName: '--folder', + parameterShortName: '-f', + argumentName: 'FOLDER', + description: + 'The folders that need to be scanned, default is src and lib.' + + 'Normally we can input all the folders under the project directory, excluding the ignored folders.' + }); + this._projects = this.defineStringListParameter({ + parameterLongName: '--only', + parameterShortName: '-o', + argumentName: 'PROJECT', + description: 'Projects that need to be checked for phantom dependencies.' + }); + this._terminal = parser.terminal; } - protected async runAsync(): Promise<void> { - const packageJsonFilename: string = path.resolve('./package.json'); - - if (!FileSystem.exists(packageJsonFilename)) { - throw new Error('You must run "rush scan" in a project folder containing a package.json file.'); - } + private async _scanAsync(params: { + packageJsonFilePath: string; + folders: readonly string[]; + glob: typeof FastGlob; + terminal: ITerminal; + }): Promise<IScanResult> { + const { packageJsonFilePath, folders, glob, terminal } = params; + const packageJsonFilename: string = path.resolve(packageJsonFilePath); const requireRegExps: RegExp[] = [ // Example: require('something') @@ -114,8 +136,13 @@ export class ScanAction extends BaseConfiglessRushAction { const requireMatches: Set<string> = new Set<string>(); - const { default: glob } = await import('fast-glob'); - const scanResults: string[] = await glob(['./*.{ts,js,tsx,jsx}', './{src,lib}/**/*.{ts,js,tsx,jsx}']); + const scanResults: string[] = await glob( + [ + './*.{ts,js,tsx,jsx}', + `./${folders.length > 1 ? '{' + folders.join(',') + '}' : folders[0]}/**/*.{ts,js,tsx,jsx}` + ], + { cwd: path.dirname(packageJsonFilePath), absolute: true } + ); for (const filename of scanResults) { try { const contents: string = FileSystem.readFile(filename); @@ -131,7 +158,8 @@ export class ScanAction extends BaseConfiglessRushAction { } } catch (error) { // eslint-disable-next-line no-console - console.log(Colorize.bold('Skipping file due to error: ' + filename)); + console.log(error); + terminal.writeErrorLine(Colorize.bold('Skipping file due to error: ' + filename)); } } @@ -175,8 +203,7 @@ export class ScanAction extends BaseConfiglessRushAction { } } } catch (e) { - // eslint-disable-next-line no-console - console.error(`JSON.parse ${packageJsonFilename} error`); + terminal.writeErrorLine(`JSON.parse ${packageJsonFilename} error`); } for (const detectedPkgName of detectedPackageNames) { @@ -200,65 +227,108 @@ export class ScanAction extends BaseConfiglessRushAction { } } - const output: IJsonOutput = { + const output: IScanResult = { detectedDependencies: detectedPackageNames, missingDependencies: missingDependencies, unusedDependencies: unusedDependencies }; + return output; + } + + private _getPackageJsonPathsFromProjects(projectNames: readonly string[]): string[] { + const result: string[] = []; + if (!this.rushConfiguration) { + throw new Error(``); + } + for (const projectName of projectNames) { + const project: RushConfigurationProject | undefined = + this.rushConfiguration.getProjectByName(projectName); + if (!project) { + throw new Error(``); + } + const packageJsonFilePath: string = path.join(project.projectFolder, FileConstants.PackageJson); + result.push(packageJsonFilePath); + } + return result; + } + + protected async runAsync(): Promise<void> { + const packageJsonFilePaths: string[] = this._projects.values.length + ? this._getPackageJsonPathsFromProjects(this._projects.values) + : [path.resolve('./package.json')]; + const { default: glob } = await import('fast-glob'); + const folders: readonly string[] = this._folders.values.length ? this._folders.values : ['src', 'lib']; + + const output: Record<string, IScanResult> = {}; + + for (const packageJsonFilePath of packageJsonFilePaths) { + if (!FileSystem.exists(packageJsonFilePath)) { + throw new Error(`${packageJsonFilePath} is not exist`); + } + const packageName: string = JsonFile.load(packageJsonFilePath).name; + const scanResult: IScanResult = await this._scanAsync({ + packageJsonFilePath, + folders, + glob, + terminal: this._terminal + }); + output[packageName] = scanResult; + } if (this._jsonFlag.value) { - // eslint-disable-next-line no-console - console.log(JSON.stringify(output, undefined, 2)); + this._terminal.writeLine(JSON.stringify(output, undefined, 2)); } else if (this._allFlag.value) { - if (detectedPackageNames.length !== 0) { - // eslint-disable-next-line no-console - console.log('Dependencies that seem to be imported by this project:'); - for (const packageName of detectedPackageNames) { - // eslint-disable-next-line no-console - console.log(' ' + packageName); + for (const [packageName, scanResult] of Object.entries(output)) { + this._terminal.writeLine(`-------------------- ${packageName} result start --------------------`); + const { detectedDependencies } = scanResult; + if (detectedDependencies.length !== 0) { + this._terminal.writeLine(`Dependencies that seem to be imported by this project ${packageName}:`); + for (const detectedDependency of detectedDependencies) { + this._terminal.writeLine(' ' + detectedDependency); + } + } else { + this._terminal.writeLine(`This project ${packageName} does not seem to import any NPM packages.`); } - } else { - // eslint-disable-next-line no-console - console.log('This project does not seem to import any NPM packages.'); + this._terminal.writeLine(`-------------------- ${packageName} result end --------------------`); } } else { - let wroteAnything: boolean = false; - - if (missingDependencies.length > 0) { - // eslint-disable-next-line no-console - console.log( - Colorize.yellow('Possible phantom dependencies') + - " - these seem to be imported but aren't listed in package.json:" - ); - for (const packageName of missingDependencies) { - // eslint-disable-next-line no-console - console.log(' ' + packageName); + for (const [packageName, scanResult] of Object.entries(output)) { + this._terminal.writeLine(`-------------------- ${packageName} result start --------------------`); + const { missingDependencies, unusedDependencies } = scanResult; + let wroteAnything: boolean = false; + + if (missingDependencies.length > 0) { + this._terminal.writeWarningLine( + Colorize.yellow('Possible phantom dependencies') + + " - these seem to be imported but aren't listed in package.json:" + ); + for (const missingDependency of missingDependencies) { + this._terminal.writeLine(' ' + missingDependency); + } + wroteAnything = true; } - wroteAnything = true; - } - if (unusedDependencies.length > 0) { - if (wroteAnything) { - // eslint-disable-next-line no-console - console.log(''); + if (unusedDependencies.length > 0) { + if (wroteAnything) { + this._terminal.writeLine(''); + } + this._terminal.writeWarningLine( + Colorize.yellow('Possible unused dependencies') + + " - these are listed in package.json but don't seem to be imported:" + ); + for (const unusedDependency of unusedDependencies) { + this._terminal.writeLine(' ' + unusedDependency); + } + wroteAnything = true; } - // eslint-disable-next-line no-console - console.log( - Colorize.yellow('Possible unused dependencies') + - " - these are listed in package.json but don't seem to be imported:" - ); - for (const packageName of unusedDependencies) { - // eslint-disable-next-line no-console - console.log(' ' + packageName); + + if (!wroteAnything) { + this._terminal.writeLine( + Colorize.green('Everything looks good.') + ' No missing or unused dependencies were found.' + ); } - wroteAnything = true; - } - if (!wroteAnything) { - // eslint-disable-next-line no-console - console.log( - Colorize.green('Everything looks good.') + ' No missing or unused dependencies were found.' - ); + this._terminal.writeLine(`-------------------- ${packageName} result end --------------------`); } } } diff --git a/libraries/rush-lib/src/cli/actions/test/ScanAction.test.ts b/libraries/rush-lib/src/cli/actions/test/ScanAction.test.ts new file mode 100644 index 00000000000..8c5cf588fd0 --- /dev/null +++ b/libraries/rush-lib/src/cli/actions/test/ScanAction.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +import '../../test/mockRushCommandLineParser'; + +import '../../test/mockRushCommandLineParser'; +import { ScanAction } from '../ScanAction'; +import { RushCommandLineParser } from '../../RushCommandLineParser'; + +import { Terminal } from '@rushstack/terminal'; + +describe.skip(ScanAction.name, () => { + describe('basic "rush remove" tests', () => { + let terminalMock: jest.SpyInstance; + let oldExitCode: number | undefined; + let oldArgs: string[]; + + beforeEach(() => { + terminalMock = jest.spyOn(Terminal.prototype, 'write').mockImplementation(() => {}); + + jest.spyOn(process, 'exit').mockImplementation(); + + oldExitCode = process.exitCode; + oldArgs = process.argv; + }); + + afterEach(() => { + jest.clearAllMocks(); + process.exitCode = oldExitCode; + process.argv = oldArgs; + }); + + describe("'scan' action", () => { + it(`scan the repository to find phantom dependencies. `, async () => { + const aPath: string = `${__dirname}/scanRepo/a`; + + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: aPath }); + + jest.spyOn(process, 'cwd').mockReturnValue(aPath); + + process.argv = ['pretend-this-is-node.exe', 'pretend-this-is-rush', 'scan', '--json']; + await expect(parser.executeAsync()).resolves.toEqual(true); + expect(terminalMock).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/libraries/rush-lib/src/cli/actions/test/scanRepo/.gitignore b/libraries/rush-lib/src/cli/actions/test/scanRepo/.gitignore new file mode 100644 index 00000000000..6ddd556a5a2 --- /dev/null +++ b/libraries/rush-lib/src/cli/actions/test/scanRepo/.gitignore @@ -0,0 +1 @@ +common/temp diff --git a/libraries/rush-lib/src/cli/actions/test/scanRepo/a/index.ts b/libraries/rush-lib/src/cli/actions/test/scanRepo/a/index.ts new file mode 100644 index 00000000000..9cbb014c4b4 --- /dev/null +++ b/libraries/rush-lib/src/cli/actions/test/scanRepo/a/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable import/no-extraneous-dependencies */ +import { ConsoleTerminalProvider, Terminal } from '@rushstack/terminal'; diff --git a/libraries/rush-lib/src/cli/actions/test/scanRepo/a/package.json b/libraries/rush-lib/src/cli/actions/test/scanRepo/a/package.json new file mode 100644 index 00000000000..fa82832c694 --- /dev/null +++ b/libraries/rush-lib/src/cli/actions/test/scanRepo/a/package.json @@ -0,0 +1,8 @@ +{ + "name": "a", + "version": "1.0.0", + "description": "Test package a", + "dependencies": { + "assert": "workspace:*" + } +} diff --git a/libraries/rush-lib/src/cli/actions/test/scanRepo/b/package.json b/libraries/rush-lib/src/cli/actions/test/scanRepo/b/package.json new file mode 100644 index 00000000000..a345432fe3c --- /dev/null +++ b/libraries/rush-lib/src/cli/actions/test/scanRepo/b/package.json @@ -0,0 +1,9 @@ +{ + "name": "b", + "version": "1.0.0", + "description": "Test package b", + "dependencies": { + "assert": "workspace:*", + "rimraf": "workspace:*" + } +} diff --git a/libraries/rush-lib/src/cli/actions/test/scanRepo/b/test-folder1/index.ts b/libraries/rush-lib/src/cli/actions/test/scanRepo/b/test-folder1/index.ts new file mode 100644 index 00000000000..9cbb014c4b4 --- /dev/null +++ b/libraries/rush-lib/src/cli/actions/test/scanRepo/b/test-folder1/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable import/no-extraneous-dependencies */ +import { ConsoleTerminalProvider, Terminal } from '@rushstack/terminal'; diff --git a/libraries/rush-lib/src/cli/actions/test/scanRepo/rush.json b/libraries/rush-lib/src/cli/actions/test/scanRepo/rush.json new file mode 100644 index 00000000000..f39da1606c4 --- /dev/null +++ b/libraries/rush-lib/src/cli/actions/test/scanRepo/rush.json @@ -0,0 +1,17 @@ +{ + "npmVersion": "6.4.1", + "rushVersion": "5.5.2", + "projectFolderMinDepth": 1, + "projectFolderMaxDepth": 99, + + "projects": [ + { + "packageName": "a", + "projectFolder": "a" + }, + { + "packageName": "b", + "projectFolder": "b" + } + ] +}