Skip to content

Commit

Permalink
feat: modify rush scan, support executing projects under rush and cus…
Browse files Browse the repository at this point in the history
…tom scanning folders.
  • Loading branch information
sherlockfeng committed Dec 16, 2024
1 parent fc48c64 commit 56179b4
Show file tree
Hide file tree
Showing 9 changed files with 216 additions and 61 deletions.
Original file line number Diff line number Diff line change
@@ -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"
}
184 changes: 123 additions & 61 deletions libraries/rush-lib/src/cli/actions/ScanAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@

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';

export interface IJsonOutput {
export interface IScanResult {
/**
* Dependencies scan from source code
*/
Expand All @@ -26,8 +27,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({
Expand All @@ -40,7 +44,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.`,
Expand All @@ -56,14 +60,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 scan(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')
Expand Down Expand Up @@ -114,8 +135,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);
Expand All @@ -130,8 +156,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));
}
}

Expand Down Expand Up @@ -175,8 +201,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) {
Expand All @@ -200,65 +225,102 @@ 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 = 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 = JsonFile.load(packageJsonFilePath).name;
const scanResult = await this.scan({ 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 packageName of detectedDependencies) {
this._terminal.writeLine(' ' + packageName);
}
} 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 packageName of missingDependencies) {
this._terminal.writeLine(' ' + packageName);
}
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 packageName of unusedDependencies) {
this._terminal.writeLine(' ' + packageName);
}
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 --------------------`);
}
}
}
Expand Down
46 changes: 46 additions & 0 deletions libraries/rush-lib/src/cli/actions/test/ScanAction.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
common/temp
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { ConsoleTerminalProvider, Terminal } from '@rushstack/terminal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "a",
"version": "1.0.0",
"description": "Test package a",
"dependencies": {
"assert": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "b",
"version": "1.0.0",
"description": "Test package b",
"dependencies": {
"assert": "workspace:*",
"rimraf": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { ConsoleTerminalProvider, Terminal } from '@rushstack/terminal';
17 changes: 17 additions & 0 deletions libraries/rush-lib/src/cli/actions/test/scanRepo/rush.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}

0 comments on commit 56179b4

Please sign in to comment.