diff --git a/.vscode/launch.json b/.vscode/launch.json index 00b6f4ef11e..7736568abfe 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -44,6 +44,30 @@ "type": "node", "request": "attach", "port": 5858 + }, + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/apps/vscode-extension" + ], + "outFiles": [ + "${workspaceFolder}/apps/vscode-extension/dist/**/*.js" + ] + }, + { + "name": "Extension Tests", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/apps/vscode-extension", + "--extensionTestsPath=${workspaceFolder}/apps/vscode-extension/out/test/suite/index" + ], + "outFiles": [ + "${workspaceFolder}/apps/vscode-extension/out/**/*.js", + "${workspaceFolder}/apps/vscode-extension/dist/**/*.js" + ] } ] } diff --git a/apps/vscode-extension/.eslintrc.json b/apps/vscode-extension/.eslintrc.json new file mode 100644 index 00000000000..5dfecab7e78 --- /dev/null +++ b/apps/vscode-extension/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/naming-convention": "warn", + "@typescript-eslint/semi": "warn", + "curly": "warn", + "eqeqeq": "warn", + "no-throw-literal": "warn", + "semi": "off" + }, + "ignorePatterns": ["out", "dist", "**/*.d.ts"] +} diff --git a/apps/vscode-extension/.vscodeignore b/apps/vscode-extension/.vscodeignore new file mode 100644 index 00000000000..c6136798a58 --- /dev/null +++ b/apps/vscode-extension/.vscodeignore @@ -0,0 +1,13 @@ +.vscode/** +.vscode-test/** +out/** +node_modules/** +src/** +.gitignore +.yarnrc +webpack.config.js +vsc-extension-quickstart.md +**/tsconfig.json +**/.eslintrc.json +**/*.map +**/*.ts diff --git a/apps/vscode-extension/CHANGELOG.md b/apps/vscode-extension/CHANGELOG.md new file mode 100644 index 00000000000..4d8271f69ec --- /dev/null +++ b/apps/vscode-extension/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log + +All notable changes to the "rush" extension will be documented in this file. + +Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. + +## [Unreleased] + +- Initial release \ No newline at end of file diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md new file mode 100644 index 00000000000..45fb53ed231 --- /dev/null +++ b/apps/vscode-extension/README.md @@ -0,0 +1,71 @@ +# rush README + +This is the README for your extension "rush". After writing up a brief description, we recommend including the following sections. + +## Features + +Describe specific features of your extension including screenshots of your extension in action. Image paths are relative to this README file. + +For example if there is an image subfolder under your extension project workspace: + +\!\[feature X\]\(images/feature-x.png\) + +> Tip: Many popular extensions utilize animations. This is an excellent way to show off your extension! We recommend short, focused animations that are easy to follow. + +## Requirements + +If you have any requirements or dependencies, add a section describing those and how to install and configure them. + +## Extension Settings + +Include if your extension adds any VS Code settings through the `contributes.configuration` extension point. + +For example: + +This extension contributes the following settings: + +* `myExtension.enable`: Enable/disable this extension. +* `myExtension.thing`: Set to `blah` to do something. + +## Known Issues + +Calling out known issues can help limit users opening duplicate issues against your extension. + +## Release Notes + +Users appreciate release notes as you update your extension. + +### 1.0.0 + +Initial release of ... + +### 1.0.1 + +Fixed issue #. + +### 1.1.0 + +Added features X, Y, and Z. + +--- + +## Following extension guidelines + +Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension. + +* [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) + +## Working with Markdown + +You can author your README using Visual Studio Code. Here are some useful editor keyboard shortcuts: + +* Split the editor (`Cmd+\` on macOS or `Ctrl+\` on Windows and Linux). +* Toggle preview (`Shift+Cmd+V` on macOS or `Shift+Ctrl+V` on Windows and Linux). +* Press `Ctrl+Space` (Windows, Linux, macOS) to see a list of Markdown snippets. + +## For more information + +* [Visual Studio Code's Markdown Support](http://code.visualstudio.com/docs/languages/markdown) +* [Markdown Syntax Reference](https://help.github.com/articles/markdown-basics/) + +**Enjoy!** diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json new file mode 100644 index 00000000000..b0c2661b626 --- /dev/null +++ b/apps/vscode-extension/package.json @@ -0,0 +1,258 @@ +{ + "name": "@rushstack/vscode-extension", + "displayName": "Rush", + "description": "Official extension for Rush", + "version": "0.0.1", + "engines": { + "vscode": "^1.71.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onView:rushProjects" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "rush.revealProjectInExplorer", + "title": "Reveal in Explorer view", + "category": "Rush", + "icon": "$(folder)" + }, + { + "command": "rush.activateProjectForResource", + "title": "Activate project in Rush", + "category": "Rush", + "icon": "$(add)" + }, + { + "command": "rush.activateProject", + "title": "Activate", + "category": "Rush", + "icon": "$(add)" + }, + { + "command": "rush.deactivateProject", + "title": "Deactivate", + "category": "Rush", + "icon": "$(remove)" + }, + { + "command": "rush.enableWatch", + "title": "Enable watch mode", + "category": "Rush", + "icon": "$(eye)" + }, + { + "command": "rush.disableWatch", + "title": "Disable watch mode", + "category": "Rush", + "icon": "$(eye-closed)" + }, + { + "command": "rush.runAction", + "title": "Run", + "category": "Rush", + "icon": "$(run)" + }, + { + "command": "rush.setWatchAction", + "title": "Set as watch command", + "category": "Rush", + "icon": "$(eye)" + }, + { + "command": "rush.refreshProjects", + "title": "Refresh projects", + "category": "Rush", + "icon": "$(refresh)" + }, + { + "command": "rush.refreshPhases", + "title": "Refresh phases", + "category": "Rush", + "icon": "$(refresh)" + } + ], + "configuration": { + "title": "Rush", + "properties": { + "rush.useWorkspaceRushVersion": { + "type": "boolean", + "default": true, + "description": "Specifies whether to use the version of Rush from the workspace" + } + } + }, + "menus": { + "editor/title": [ + { + "command": "rush.activateProjectForResource", + "when": "rush.enabled" + } + ], + "view/item/context": [ + { + "command": "rush.revealProjectInExplorer", + "when": "view == rushProjects && viewItem =~ /^project:/", + "group": "inline" + }, + { + "command": "rush.revealProjectInExplorer", + "when": "view == rushProjects && viewItem =~ /^project:/" + }, + { + "command": "rush.activateProject", + "when": "view == rushProjects && viewItem =~ /^project:(Available|Included)/", + "group": "inline" + }, + { + "command": "rush.deactivateProject", + "when": "view == rushProjects && viewItem =~ /^project:Active/", + "group": "inline" + }, + { + "command": "rush.activateProject", + "when": "view == rushProjects && viewItem == group:Included && rush.includedProjects.count > 0", + "group": "inline" + }, + { + "command": "rush.activateProject", + "when": "view == rushProjects && viewItem == group:Available && rush.availableProjects.count > 0", + "group": "inline" + }, + { + "command": "rush.deactivateProject", + "when": "view == rushProjects && viewItem == group:Active && rush.activeProjects.count > 0", + "group": "inline" + }, + { + "command": "rush.activateProject", + "when": "view == rushProjects && viewItem =~ /^project:(Available|Included)/" + }, + { + "command": "rush.deactivateProject", + "when": "view == rushProjects && viewItem =~ /^project:Active/" + }, + { + "command": "rush.activatePhase", + "when": "view == rushPhases && viewItem =~ /^phase:(Available|Included)/", + "group": "inline" + }, + { + "command": "rush.deactivatePhase", + "when": "view == rushPhases && viewItem =~ /^phase:Active/", + "group": "inline" + }, + { + "command": "rush.activatePhase", + "when": "view == rushPhases && viewItem == group:Included && rush.includedPhases.count > 0", + "group": "inline" + }, + { + "command": "rush.activatePhase", + "when": "view == rushPhases && viewItem == group:Available && rush.availablePhases.count > 0", + "group": "inline" + }, + { + "command": "rush.deactivatePhase", + "when": "view == rushPhases && viewItem == group:Active && rush.activePhases.count > 0", + "group": "inline" + } + ], + "view/title": [ + { + "command": "rush.enableWatch", + "when": "view == rushProjects && rush.watcher == 'sleep' && rush.watchAction != ''", + "group": "navigation" + }, + { + "command": "rush.disableWatch", + "when": "view == rushProjects && rush.watcher == 'starting'", + "group": "navigation" + }, + { + "command": "rush.disableWatch", + "when": "view == rushProjects && rush.watcher == 'ready'", + "group": "navigation" + }, + { + "command": "rush.refreshProjects", + "when": "view == rushProjects", + "group": "navigation" + }, + { + "command": "rush.refreshPhases", + "when": "view == rushPhases", + "group": "navigation" + } + ] + }, + "views": { + "rush": [ + { + "id": "rushProjects", + "name": "Projects" + }, + { + "id": "rushPhases", + "name": "Phases" + } + ] + }, + "viewsWelcome": [ + { + "view": "rushProjects", + "contents": "Welcome to Rush!\n\nIf this view does not load, make sure that this workspace is using Rush and that Rush has been installed by entering `rush install`. Then reload this window." + }, + { + "view": "rushPhases", + "contents": "Available phases for your workspace's interactive build command will appear here." + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "rush", + "title": "Rush", + "icon": "resources/rush.svg" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "pnpm run package", + "compile": "webpack", + "watch": "webpack --watch", + "package": "webpack --mode production --devtool hidden-source-map", + "compile-tests": "tsc -p . --outDir out", + "watch-tests": "tsc -p . -w --outDir out", + "pretest": "pnpm run compile-tests && pnpm run compile && pnpm run lint", + "lint": "eslint src --ext ts", + "test": "node ./out/test/runTest.js" + }, + "dependencies": { + "@rushstack/rush-sdk": "workspace:*" + }, + "devDependencies": { + "@types/vscode": "^1.71.0", + "@types/glob": "7.1.1", + "@types/mocha": "^9.1.1", + "@types/node": "12.20.24", + "@typescript-eslint/eslint-plugin": "~5.30.3", + "@typescript-eslint/parser": "~5.30.3", + "eslint": "~8.7.0", + "glob": "~7.0.5", + "mocha": "^10.0.0", + "typescript": "~4.7.4", + "ts-loader": "9.4.0", + "webpack": "~5.68.0", + "webpack-cli": "~4.10.0", + "@microsoft/rush": "workspace:*", + "@microsoft/rush-lib": "workspace:*", + "@rushstack/webpack-preserve-dynamic-require-plugin": "workspace:*", + "@vscode/test-electron": "^2.1.5" + } +} diff --git a/apps/vscode-extension/resources/rush.svg b/apps/vscode-extension/resources/rush.svg new file mode 100644 index 00000000000..f63fa999ab2 --- /dev/null +++ b/apps/vscode-extension/resources/rush.svg @@ -0,0 +1,69 @@ + + + +Rushimage/svg+xmlRushhttps://github.com/pgonzalCopyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. \ No newline at end of file diff --git a/apps/vscode-extension/src/dataProviders/phases/PhaseDataProvider.ts b/apps/vscode-extension/src/dataProviders/phases/PhaseDataProvider.ts new file mode 100644 index 00000000000..3659a1e8dc5 --- /dev/null +++ b/apps/vscode-extension/src/dataProviders/phases/PhaseDataProvider.ts @@ -0,0 +1,291 @@ +import * as vscode from 'vscode'; + +import type * as Rush from '@rushstack/rush-sdk'; + +export type StateGroupName = 'Active' | 'Included' | 'Available'; + +export interface IPhaseDataProviderParams { + workspaceRoot: string; + extensionContext: vscode.ExtensionContext; + rush: typeof Rush; +} + +export class PhaseDataProvider implements vscode.TreeDataProvider { + private _workspaceRoot: string; + private _onDidChangeTreeData: vscode.EventEmitter< + PhaseStateGroup | Phase | (PhaseStateGroup | Phase)[] | undefined + >; + + private _stateGroups: PhaseStateGroup[]; + private _groupByState: Record; + private _rush: typeof Rush; + + private _phasesByName: Map; + private _extensionContext: vscode.ExtensionContext; + + constructor(params: IPhaseDataProviderParams) { + const { workspaceRoot, rush, extensionContext } = params; + + this._workspaceRoot = workspaceRoot; + this._rush = rush; + this._extensionContext = extensionContext; + + this._onDidChangeTreeData = new vscode.EventEmitter(); + + const groupByState: Record = { + Active: new PhaseStateGroup('Active', [ + new Message('To get started, activate phases from this repository.') + ]), + Included: new PhaseStateGroup('Included', [ + new Message('Dependencies of active phases will appear here.') + ]), + Available: new PhaseStateGroup('Available', []) + }; + + this._phasesByName = new Map(); + this._groupByState = groupByState; + + this._stateGroups = Object.values(groupByState); + } + + public async refresh(): Promise { + this._phasesByName.clear(); + + await Promise.all([ + vscode.commands.executeCommand('setContext', 'rush.activePhases.count', 0), + vscode.commands.executeCommand('setContext', 'rush.includedPhases.count', 0), + vscode.commands.executeCommand('setContext', 'rush.availablePhases.count', 0) + ]); + + console.log('Updating tree data', 3, Date.now()); + this._onDidChangeTreeData.fire(this._stateGroups); + console.log('Updated tree data', 3, Date.now()); + + if (!this._rush.RushCommandLineParser) { + return; + } + + const commandLineParser = new this._rush.RushCommandLineParser({ + cwd: this._workspaceRoot, + excludeDefaultActions: true + }); + + const command = commandLineParser.tryGetAction('start') as + | { _knownPhases: ReadonlyMap } + | undefined; + if (!command) { + console.warn(`This repository does not define an action named 'start'`); + return; + } + + const phaseByRushPhase = new Map(); + const sortedPhases: Phase[] = Array.from(command._knownPhases.values(), (rushPhase: Rush.IPhase) => { + const phase = new Phase(rushPhase); + phaseByRushPhase.set(rushPhase, phase); + return phase; + }); + + sortedPhases.sort(phaseNameSort); + + for (const phase of sortedPhases) { + for (const dependency of phase.rushPhase.dependencies.self) { + const dependencyPhase = phaseByRushPhase.get(dependency); + if (dependencyPhase) { + phase.dependencies.add(dependencyPhase); + } + } + this._phasesByName.set(phase.rushPhase.name, phase); + } + + const activePhases: Phase[] = []; + + const activePhasesState = + this._extensionContext.workspaceState.get<{ [key: string]: true }>('rush.activePhases'); + + if (activePhasesState) { + for (const projectName of Object.keys(activePhasesState)) { + const phase = this._phasesByName.get(projectName); + + if (phase) { + activePhases.push(phase); + } + } + } + + await this.toggleActivePhases(activePhases, true); + } + + public getPhasesByState(state: StateGroupName): ReadonlyArray { + return this._groupByState[state].children; + } + + public async toggleActivePhases(togglePhases: Phase[], force?: boolean): Promise { + console.log('Toggling active phases', togglePhases.length, Date.now()); + + if (togglePhases.length === 0) { + return; + } + + const destinationStateGroup = + force ?? togglePhases[0].stateGroupName !== 'Active' ? 'Active' : 'Available'; + for (const phase of togglePhases) { + phase.stateGroupName = destinationStateGroup; + } + + const activePhasesState: { + [key: string]: true; + } = {}; + + const queue: Set = new Set(); + for (const phase of this._phasesByName.values()) { + if (phase.stateGroupName === 'Active') { + queue.add(phase); + activePhasesState[phase.rushPhase.name] = true; + } else if (phase.stateGroupName === 'Included') { + phase.stateGroupName = 'Available'; + } + } + + this._extensionContext.workspaceState.update('rush.activePhases', activePhasesState); + + for (const phase of queue) { + if (phase.stateGroupName === 'Available') { + phase.stateGroupName = 'Included'; + } + + for (const dependency of phase.dependencies) { + queue.add(dependency); + } + } + + const groupByState = this._groupByState; + for (const group of this._stateGroups) { + group.children.length = 0; + } + + for (const phase of this._phasesByName.values()) { + groupByState[phase.stateGroupName].children.push(phase); + } + + await Promise.all([ + vscode.commands.executeCommand( + 'setContext', + 'rush.activePhases.count', + groupByState.Active.children.length + ), + vscode.commands.executeCommand( + 'setContext', + 'rush.includedPhases.count', + groupByState.Included.children.length + ), + vscode.commands.executeCommand( + 'setContext', + 'rush.availablePhases.count', + groupByState.Available.children.length + ) + ]); + + console.log('Updating tree data', 3, Date.now()); + this._onDidChangeTreeData.fire(this._stateGroups); + console.log('Updated tree data', 3, Date.now()); + + console.log('Toggled active phases', togglePhases.length, Date.now()); + } + + public get onDidChangeTreeData(): vscode.Event< + PhaseStateGroup | Phase | (PhaseStateGroup | Phase)[] | undefined + > { + return this._onDidChangeTreeData.event; + } + + public getChildren(element?: PhaseStateGroup | Phase | undefined): (PhaseStateGroup | Phase | Message)[] { + if (!element) { + return this._stateGroups; + } else if (element instanceof PhaseStateGroup) { + const children: Phase[] = element.children; + + if (children.length === 0) { + return element.defaultChildren; + } + + return children; + } + + return []; + } + + public getParent(element: Phase): vscode.ProviderResult { + return undefined; + } + + public getTreeItem(element: Phase | PhaseStateGroup | Message): vscode.TreeItem { + return element.renderTreeItem(); + } +} + +export class Phase { + public readonly rushPhase: Rush.IPhase; + public readonly dependencies: Set; + + public stateGroupName: StateGroupName; + + constructor(rushPhase: Rush.IPhase) { + this.rushPhase = rushPhase; + this.dependencies = new Set(); + + this.stateGroupName = 'Available'; + } + + public renderTreeItem(): vscode.TreeItem { + const phaseName: string = this.rushPhase.name.replace(/^_phase:/, ''); + const treeItem = new vscode.TreeItem(phaseName, vscode.TreeItemCollapsibleState.None); + + treeItem.contextValue = `phase:${this.stateGroupName}`; + + treeItem.id = `phase:${phaseName}`; + + return treeItem; + } +} + +export class PhaseStateGroup { + public readonly groupName: StateGroupName; + public readonly defaultChildren: Message[]; + public readonly children: Phase[]; + + constructor(groupName: StateGroupName, defaultChildren: Message[]) { + this.groupName = groupName; + this.defaultChildren = defaultChildren; + this.children = []; + } + + public renderTreeItem(): vscode.TreeItem { + const treeItem = new vscode.TreeItem(this.groupName, vscode.TreeItemCollapsibleState.Expanded); + + treeItem.contextValue = `group:${this.groupName}`; + + treeItem.id = `group:${this.groupName}`; + + return treeItem; + } +} + +export class Message { + public readonly label: string; + + constructor(label: string) { + this.label = label; + } + + public renderTreeItem(): vscode.TreeItem { + const treeItem = new vscode.TreeItem(this.label, vscode.TreeItemCollapsibleState.None); + + treeItem.id = `message:${this.label}`; + + return treeItem; + } +} + +function phaseNameSort(a: Phase, b: Phase): number { + return a.rushPhase.name.localeCompare(b.rushPhase.name); +} diff --git a/apps/vscode-extension/src/dataProviders/projects/ProjectDataProvider.ts b/apps/vscode-extension/src/dataProviders/projects/ProjectDataProvider.ts new file mode 100644 index 00000000000..291be5c0db6 --- /dev/null +++ b/apps/vscode-extension/src/dataProviders/projects/ProjectDataProvider.ts @@ -0,0 +1,639 @@ +import * as vscode from 'vscode'; +import type * as Rush from '@rushstack/rush-sdk'; +import * as path from 'path'; + +export type StateGroupName = 'Active' | 'Included' | 'Available'; + +export interface IProjectDataProviderParams { + workspaceRoot: string; + extensionContext: vscode.ExtensionContext; + rush: typeof Rush; +} + +interface IStatusAndActive { + status: Rush.OperationStatus; + active: boolean; +} + +export class ProjectDataProvider + implements + vscode.TreeDataProvider, + vscode.FileDecorationProvider +{ + private _workspaceRoot: string; + private _onDidChangeTreeData: vscode.EventEmitter< + StateGroup | Project | OperationPhase | (StateGroup | Project | OperationPhase)[] | undefined + >; + private _onDidChangeFileDecorations: vscode.EventEmitter; + + private _stateGroups: StateGroup[]; + + private _activeGroup: StateGroup; + private _includedGroup: StateGroup; + private _availableGroup: StateGroup; + private _rush: typeof Rush; + + private _projectsByName: Map; + private _extensionContext: vscode.ExtensionContext; + + private _lookup: Rush.LookupByPath; + + constructor(params: IProjectDataProviderParams) { + const { workspaceRoot, rush, extensionContext } = params; + + this._workspaceRoot = workspaceRoot; + this._rush = rush; + this._extensionContext = extensionContext; + + this._onDidChangeTreeData = new vscode.EventEmitter(); + this._onDidChangeFileDecorations = new vscode.EventEmitter(); + + this._activeGroup = new StateGroup('Active'); + this._includedGroup = new StateGroup('Included'); + this._availableGroup = new StateGroup('Available'); + + this._projectsByName = new Map(); + this._lookup = new rush.LookupByPath(); + + this._stateGroups = [this._activeGroup, this._includedGroup, this._availableGroup]; + } + + public async refresh(): Promise { + this._activeGroup.projects.clear(); + this._includedGroup.projects.clear(); + this._availableGroup.projects.clear(); + this._projectsByName.clear(); + + await Promise.all([ + vscode.commands.executeCommand('setContext', 'rush.activeProjects.count', 0), + vscode.commands.executeCommand('setContext', 'rush.includedProjects.count', 0), + vscode.commands.executeCommand('setContext', 'rush.availableProjects.count', 0) + ]); + + console.log('Updating tree data', 3, Date.now()); + this._onDidChangeTreeData.fire([this._activeGroup, this._includedGroup, this._availableGroup]); + console.log('Updated tree data', 3, Date.now()); + + if (!this._rush.RushConfiguration) { + return; + } + + const rushConfigurationFile = this._rush.RushConfiguration.tryFindRushJsonLocation({ + startingFolder: this._workspaceRoot + }); + + if (!rushConfigurationFile) { + return; + } + + const rushConfiguration = this._rush.RushConfiguration.loadFromConfigurationFile(rushConfigurationFile); + + if (!rushConfiguration) { + return; + } + + this._lookup = new this._rush.LookupByPath(undefined, path.sep); + + vscode.commands.executeCommand('setContext', 'rush.enabled', true); + + for (const rushProject of rushConfiguration.projects) { + const project = new Project(rushProject); + + this._projectsByName.set(rushProject.packageName, project); + this._availableGroup.projects.add(project); + + this._lookup.setItem(vscode.Uri.file(rushProject.projectFolder).fsPath, project); + } + + await Promise.all([ + vscode.commands.executeCommand( + 'setContext', + 'rush.activeProjects.count', + this._activeGroup.projects.size + ), + vscode.commands.executeCommand( + 'setContext', + 'rush.includedProjects.count', + this._includedGroup.projects.size + ), + vscode.commands.executeCommand( + 'setContext', + 'rush.availableProjects.count', + this._availableGroup.projects.size + ) + ]); + + const activeProjects: Project[] = []; + + const activeProjectsState = + this._extensionContext.workspaceState.get<{ [key: string]: true }>('rush.activeProjects'); + + if (activeProjectsState) { + for (const projectName of Object.keys(activeProjectsState)) { + const project = this._projectsByName.get(projectName); + + if (project) { + activeProjects.push(project); + } + } + } + + await this.toggleActiveProjects(activeProjects, true); + } + + public getActiveProjects(): Project[] { + return Array.from(this._activeGroup.projects); + } + + public getIncludedProjects(): Project[] { + return Array.from(this._includedGroup.projects); + } + + public updateProjectPhases(operationStatuses: Rush.ITransferableOperationStatus[]): void { + console.log('Updating project phases', operationStatuses.length, Date.now()); + + const updatedProjects = new Set(); + + for (const operationStatus of operationStatuses) { + const { + operation: { project: projectName, phase } + } = operationStatus; + + if (!projectName || !phase) { + continue; + } + + const project = this._projectsByName.get(projectName); + + if (!project) { + continue; + } + + let operationPhase = project.phases.get(phase); + + if (!operationPhase) { + operationPhase = new OperationPhase(project.rushProject, operationStatus); + project.phases.set(phase, operationPhase); + } + + operationPhase.operationStatus = operationStatus; + + updatedProjects.add(project); + } + + console.log('Updating tree data', updatedProjects.size, Date.now()); + if (updatedProjects.size > 10) { + this._onDidChangeTreeData.fire(undefined); + this._onDidChangeFileDecorations.fire(undefined); + } else { + this._onDidChangeTreeData.fire(Array.from(updatedProjects)); + + const resourceUris: vscode.Uri[] = []; + + for (const operationStatus of operationStatuses) { + resourceUris.push( + vscode.Uri.parse( + `rush://${operationStatus.operation.project!}?${operationStatus.operation.phase!}`, + true + ) + ); + } + + for (const project of updatedProjects) { + resourceUris.push(vscode.Uri.parse(`rush://${project.rushProject.packageName}`, true)); + resourceUris.push(vscode.Uri.file(project.rushProject.projectFolder)); + } + + this._onDidChangeFileDecorations.fire(resourceUris); + } + console.log('Updated tree data', updatedProjects.size, Date.now()); + + console.log('Updated project phases', operationStatuses.length, Date.now()); + } + + public getProjectForResource(resource: vscode.Uri): Project | undefined { + return this._lookup.findChildPath(resource.fsPath); + } + + public async toggleActiveProjects(toggleProjects: Project[], force?: boolean): Promise { + console.log('Toggling active projects', toggleProjects.length, Date.now()); + + if (toggleProjects.length === 0) { + return; + } + + this._availableGroup.projects.clear(); + + for (const project of this._projectsByName.values()) { + project.stateGroupName = 'Available'; + this._availableGroup.projects.add(project); + } + + const direction = force ?? !this._activeGroup.projects.has(toggleProjects[0]); + + if (direction) { + for (const toggleProject of toggleProjects) { + this._activeGroup.projects.add(toggleProject); + } + } else { + for (const toggleProject of toggleProjects) { + this._activeGroup.projects.delete(toggleProject); + } + } + + const seenProjects = new Set(); + + const queue: Project[] = []; + + const activeProjectsState: { + [key: string]: true; + } = {}; + + for (const activeProject of this._activeGroup.projects) { + activeProject.stateGroupName = 'Active'; + seenProjects.add(activeProject.rushProject.packageName); + this._availableGroup.projects.delete(activeProject); + queue.push(activeProject); + activeProjectsState[activeProject.rushProject.packageName] = true; + } + + this._extensionContext.workspaceState.update('rush.activeProjects', activeProjectsState); + + this._includedGroup.projects.clear(); + + let included: Project | undefined; + + while ((included = queue.shift())) { + for (const dependency of included.rushProject.dependencyProjects) { + if (!seenProjects.has(dependency.packageName)) { + const dependencyProject = this._projectsByName.get(dependency.packageName); + + if (dependencyProject) { + dependencyProject.stateGroupName = 'Included'; + this._includedGroup.projects.add(dependencyProject); + this._availableGroup.projects.delete(dependencyProject); + queue.push(dependencyProject); + } + + seenProjects.add(dependency.packageName); + } + } + } + + await Promise.all([ + vscode.commands.executeCommand( + 'setContext', + 'rush.activeProjects.count', + this._activeGroup.projects.size + ), + vscode.commands.executeCommand( + 'setContext', + 'rush.includedProjects.count', + this._includedGroup.projects.size + ), + vscode.commands.executeCommand( + 'setContext', + 'rush.availableProjects.count', + this._availableGroup.projects.size + ) + ]); + + console.log('Updating tree data', 3, Date.now()); + this._onDidChangeTreeData.fire([this._activeGroup, this._includedGroup, this._availableGroup]); + console.log('Updated tree data', 3, Date.now()); + + console.log('Toggled active projects', toggleProjects.length, Date.now()); + } + + public get onDidChangeTreeData(): vscode.Event< + StateGroup | Project | OperationPhase | (StateGroup | Project | OperationPhase)[] | undefined + > { + return this._onDidChangeTreeData.event; + } + + public get onDidChangeFileDecorations(): vscode.Event { + return this._onDidChangeFileDecorations.event; + } + + public provideFileDecoration( + uri: vscode.Uri, + token: vscode.CancellationToken + ): vscode.ProviderResult { + if (uri.scheme !== 'rush') { + return undefined; + } + + const projectName = `${uri.authority}${uri.path}`; + const phaseName = uri.query; + + const project = this._projectsByName.get(projectName); + + if (project) { + const phase = phaseName ? project.phases.get(phaseName) : undefined; + const { status, active } = phase?.operationStatus ?? getOverallStatus(project.phases.values()); + + const { badge, color } = getStatusIndicators(status, active); + + return new vscode.FileDecoration(badge, undefined, new vscode.ThemeColor(color)); + } + + return undefined; + } + + public getChildren( + element?: StateGroup | Project | OperationPhase | undefined + ): (StateGroup | Project | OperationPhase | Message)[] { + if (!element) { + return this._stateGroups; + } else if (element instanceof StateGroup) { + if (element.groupName === 'Active') { + if (this._activeGroup.projects.size === 0) { + return [new Message('To get started, activate projects from this repository.')]; + } + + return Array.from(this._activeGroup.projects).sort((a: Project, b: Project) => + a.rushProject.packageName.localeCompare(b.rushProject.packageName) + ); + } else if (element.groupName === 'Included') { + if (this._includedGroup.projects.size === 0) { + return [new Message('Dependencies of active projects will appear here.')]; + } + return Array.from(this._includedGroup.projects).sort((a: Project, b: Project) => + a.rushProject.packageName.localeCompare(b.rushProject.packageName) + ); + } else if (element.groupName === 'Available') { + return Array.from(this._availableGroup.projects).sort((a: Project, b: Project) => + a.rushProject.packageName.localeCompare(b.rushProject.packageName) + ); + } + } else if (element instanceof Project) { + return Array.from(element.phases.values()); + } + + return []; + } + + public getParent(element: Project): vscode.ProviderResult { + return undefined; + } + + public getTreeItem(element: Project | OperationPhase | StateGroup | Message): vscode.TreeItem { + if (element instanceof Message) { + const treeItem = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.None); + + treeItem.id = `message:${element.label}`; + + return treeItem; + } else if (element instanceof Project) { + const treeItem = new vscode.TreeItem( + element.rushProject.packageName, + element.phases.size > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None + ); + + treeItem.contextValue = `project:${element.stateGroupName}`; + const packageName: string = element.rushProject.packageName; + treeItem.resourceUri = vscode.Uri.parse(`rush://${packageName}`, true); + treeItem.tooltip = element.rushProject.packageJson.description; + + const status = getOverallStatus(element.phases.values()); + + const { icon, description, color } = getStatusIndicators(status.status, status.active); + + treeItem.description = description; + treeItem.iconPath = new vscode.ThemeIcon(icon, new vscode.ThemeColor(color)); + + treeItem.id = `project:${element.rushProject.packageName}`; + + return treeItem; + } else if (element instanceof OperationPhase) { + const packageName: string = element.rushProject.packageName; + const phaseName: string = element.operationStatus.operation.phase!; + + const treeItem = new vscode.TreeItem(phaseName, vscode.TreeItemCollapsibleState.None); + + treeItem.id = `phase:${packageName}/${phaseName}`; + + const { icon, description, color } = getStatusIndicators( + element.operationStatus.status, + element.operationStatus.active + ); + + treeItem.iconPath = new vscode.ThemeIcon(icon, new vscode.ThemeColor(color)); + treeItem.description = description; + treeItem.tooltip = `${element.operationStatus.hash}`; + if (element.operationStatus.operation.logFilePath) { + const uri = vscode.Uri.file(element.operationStatus.operation.logFilePath); + treeItem.resourceUri = vscode.Uri.parse(`rush://${packageName}?${phaseName}`, true); + treeItem.command = { + command: 'vscode.open', + title: 'Open', + arguments: [uri] + }; + } + + return treeItem; + } else if (element instanceof StateGroup) { + const treeItem = new vscode.TreeItem(element.groupName, vscode.TreeItemCollapsibleState.Expanded); + + treeItem.contextValue = `group:${element.groupName}`; + + treeItem.id = `group:${element.groupName}`; + + return treeItem; + } + + throw new Error('Unknown element type!'); + } +} + +export class Project { + public readonly rushProject: Rush.RushConfigurationProject; + + public stateGroupName: StateGroupName; + + public phases: Map; + + constructor(rushProject: Rush.RushConfigurationProject) { + this.rushProject = rushProject; + + this.stateGroupName = 'Available'; + + this.phases = new Map(); + } +} + +export class OperationPhase { + public readonly rushProject: Rush.RushConfigurationProject; + public operationStatus: Rush.ITransferableOperationStatus; + + constructor( + rushProject: Rush.RushConfigurationProject, + operationStatus: Rush.ITransferableOperationStatus + ) { + this.operationStatus = operationStatus; + this.rushProject = rushProject; + } +} + +export class StateGroup { + public readonly groupName: StateGroupName; + + public readonly projects: Set; + + constructor(groupName: StateGroupName) { + this.groupName = groupName; + + this.projects = new Set(); + } +} + +export class Message { + public readonly label: string; + + constructor(label: string) { + this.label = label; + } +} + +function getOverallStatus(statuses: Iterable): IStatusAndActive { + const histogram: { + [P in Rush.OperationStatus]: number; + } = { + 'FROM CACHE': 0, + 'NO OP': 0, + 'SUCCESS WITH WARNINGS': 0, + BLOCKED: 0, + EXECUTING: 0, + FAILURE: 0, + READY: 0, + SKIPPED: 0, + SUCCESS: 0 + }; + + let isActive: boolean = false; + + for (const { + operationStatus: { status, active } + } of statuses) { + histogram[status]++; + if (active) { + isActive = true; + } + } + + return { + status: mergeStatus(histogram), + active: isActive + }; +} + +function mergeStatus(histogram: { [P in Rush.OperationStatus]: number }): Rush.OperationStatus { + if (histogram.EXECUTING > 0) { + return 'EXECUTING' as Rush.OperationStatus; + } else if (histogram.READY > 0) { + return 'READY' as Rush.OperationStatus; + } else if (histogram.FAILURE > 0) { + return 'FAILURE' as Rush.OperationStatus; + } else if (histogram.BLOCKED > 0) { + return 'BLOCKED' as Rush.OperationStatus; + } else if (histogram['SUCCESS WITH WARNINGS'] > 0) { + return 'SUCCESS WITH WARNINGS' as Rush.OperationStatus; + } else if (histogram.SUCCESS > 0) { + return 'SUCCESS' as Rush.OperationStatus; + } else if (histogram['FROM CACHE'] > 0) { + return 'FROM CACHE' as Rush.OperationStatus; + } else if (histogram.SKIPPED > 0) { + return 'SKIPPED' as Rush.OperationStatus; + } else { + return 'NO OP' as Rush.OperationStatus; + } +} + +function getStatusIndicators( + status: Rush.OperationStatus, + active: boolean +): { + icon: string; + description: string; + color: string; + badge: string | undefined; +} { + let icon: string; + let description: string; + let color: string; + let badge: string | undefined; + + if (!active) { + return { + description: 'Out of scope', + icon: 'circle-outline', + color: 'disabledForeground', + badge: undefined + }; + } + + switch (status) { + case 'SUCCESS': + description = 'Succeeded'; + icon = 'pass'; + color = 'testing.iconPassed'; + badge = 'S'; + break; + case 'FROM CACHE': + description = 'Succeeded (from cache)'; + icon = 'pass'; + color = 'testing.iconPassed'; + badge = 'SC'; + break; + case 'NO OP': + description = 'Not configured'; + icon = 'circle-outline'; + color = 'disabledForeground'; + break; + case 'EXECUTING': + description = 'Executing'; + icon = 'sync~spin'; + color = 'notebookStatusRunningIcon.foreground'; + badge = 'E'; + break; + case 'SUCCESS WITH WARNINGS': + description = 'Succeeded with warnings'; + icon = 'warning'; + color = 'testing.iconFailed'; + badge = 'SW'; + break; + case 'SKIPPED': + description = 'Skipped'; + icon = 'testing-skipped-icon'; + color = 'testing.iconSkipped'; + break; + case 'FAILURE': + description = 'Failed'; + icon = 'error'; + color = 'testing.iconFailed'; + badge = 'F'; + break; + case 'BLOCKED': + description = 'Blocked'; + icon = 'stop'; + color = 'testing.iconQueued'; + badge = 'B'; + break; + default: + case 'READY': + description = 'Pending'; + icon = 'clock'; + color = 'notebookStatusRunningIcon.foreground'; + badge = 'P'; + break; + } + + return { + icon, + description, + color, + badge + }; +} diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts new file mode 100644 index 00000000000..2687d5f88d7 --- /dev/null +++ b/apps/vscode-extension/src/extension.ts @@ -0,0 +1,375 @@ +// The module 'vscode' contains the VS Code extensibility API +// Import the module and reference it with the alias vscode in your code below +import type * as Rush from '@rushstack/rush-sdk'; +import type * as RushLib from '@microsoft/rush-lib'; +import * as vscode from 'vscode'; +import { + ProjectDataProvider, + Project, + StateGroup, + OperationPhase +} from './dataProviders/projects/ProjectDataProvider'; +import { + PhaseDataProvider, + Phase, + PhaseStateGroup as PhaseStateGroup +} from './dataProviders/phases/PhaseDataProvider'; +import * as path from 'path'; + +declare const global: NodeJS.Global & + typeof globalThis & { + // eslint-disable-next-line @typescript-eslint/naming-convention + ___rush___workingDirectory?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + ___rush___rushLibModule?: typeof RushLib; + }; + +export async function activate(extensionContext: vscode.ExtensionContext) { + const workspaceRoot = + vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0 + ? vscode.workspace.workspaceFolders[0].uri.fsPath + : undefined; + + vscode.commands.executeCommand('setContext', 'rush.enabled', false); + + if (!workspaceRoot) { + return; + } + + const useWorkspaceRushVersion = + vscode.workspace.getConfiguration().get('rush.useWorkspaceRushVersion') ?? true; + + if (useWorkspaceRushVersion) { + global.___rush___workingDirectory = workspaceRoot; + } else { + global.___rush___rushLibModule = await import('@microsoft/rush-lib'); + } + + const rush = await import('@rushstack/rush-sdk'); + + if (!rush.RushCommandLineParser || !rush.PhasedCommandWorkerController) { + // This version of Rush is not supported by VS Code. + return; + } + + let worker: Rush.PhasedCommandWorkerController | undefined; + + const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); + statusBarItem.command = { + command: 'rush.disableWatch', + title: 'Disable watch' + }; + + extensionContext.subscriptions.push(statusBarItem); + + const rushDiagnostics = vscode.languages.createDiagnosticCollection('rush'); + extensionContext.subscriptions.push(rushDiagnostics); + + vscode.commands.executeCommand('setContext', 'rush.watcher', 'sleep'); + + function updateWorker(): void { + if (!worker) { + return; + } + + rushDiagnostics.clear(); + + const activeProjects = projectDataProvider.getActiveProjects(); + const activeProjectPhases = phaseDataProvider.getPhasesByState('Active'); + + const activeProjectNames = new Set( + activeProjects.map((project: Project) => project.rushProject.packageName) + ); + const activePhaseNames = new Set(activeProjectPhases.map((phase: Phase) => phase.rushPhase.name)); + + const graph = worker.getGraph(); + + const activeOperations = graph.filter( + (operation: Rush.ITransferableOperation) => + !!operation.project && + activeProjectNames.has(operation.project) && + !!operation.phase && + activePhaseNames.has(operation.phase) + ); + + worker.update(activeOperations); + } + + const projectDataProvider = new ProjectDataProvider({ + workspaceRoot, + rush, + extensionContext: extensionContext + }); + + const phaseDataProvider = new PhaseDataProvider({ + workspaceRoot, + rush, + extensionContext: extensionContext + }); + + const projectView = vscode.window.createTreeView('rushProjects', { + treeDataProvider: projectDataProvider, + canSelectMany: true, + showCollapseAll: true + }); + + extensionContext.subscriptions.push(projectView); + + const phaseView = vscode.window.createTreeView('rushPhases', { + treeDataProvider: phaseDataProvider, + canSelectMany: true, + showCollapseAll: true + }); + + extensionContext.subscriptions.push(phaseView); + + const decorationProvider = vscode.window.registerFileDecorationProvider(projectDataProvider); + + extensionContext.subscriptions.push(decorationProvider); + + const openProjectCommand = vscode.commands.registerCommand( + 'rush.revealProjectInExplorer', + (project: Project | StateGroup | OperationPhase) => { + if (project instanceof Project) { + vscode.commands.executeCommand( + 'revealInExplorer', + vscode.Uri.file(path.join(project.rushProject.projectFolder, 'package.json')) + ); + } + } + ); + + extensionContext.subscriptions.push(openProjectCommand); + + const activeProjectForResourceCommand = vscode.commands.registerCommand( + 'rush.activateProjectForResource', + async (resource: vscode.Uri) => { + const project = projectDataProvider.getProjectForResource(resource); + + if (project) { + await projectDataProvider.toggleActiveProjects([project], true); + updateWorker(); + } + } + ); + + extensionContext.subscriptions.push(activeProjectForResourceCommand); + + const refreshProjectsCommand = vscode.commands.registerCommand('rush.refreshProjects', async () => { + await projectDataProvider.refresh(); + }); + + extensionContext.subscriptions.push(refreshProjectsCommand); + + const refreshPhasesCommand = vscode.commands.registerCommand('rush.refreshPhases', async () => { + await phaseDataProvider.refresh(); + }); + + extensionContext.subscriptions.push(refreshPhasesCommand); + + const activateProjectCommand = vscode.commands.registerCommand( + 'rush.activateProject', + async ( + contextProject: Project | StateGroup | OperationPhase, + selectedProjects: (Project | StateGroup | OperationPhase)[] = [contextProject] + ) => { + if (contextProject instanceof Project) { + await projectDataProvider.toggleActiveProjects(selectedProjects as Project[], true); + } else if (contextProject instanceof StateGroup) { + if (contextProject.groupName === 'Included' || contextProject.groupName === 'Available') { + await projectDataProvider.toggleActiveProjects(Array.from(contextProject.projects), true); + } + } + + updateWorker(); + } + ); + + extensionContext.subscriptions.push(activateProjectCommand); + + const deactivateProjectCommand = vscode.commands.registerCommand( + 'rush.deactivateProject', + async ( + contextProject: Project | StateGroup | OperationPhase, + selectedProjects: (Project | StateGroup | OperationPhase)[] = [contextProject] + ) => { + if (contextProject instanceof Project) { + await projectDataProvider.toggleActiveProjects(selectedProjects as Project[], false); + } else if (contextProject instanceof StateGroup) { + if (contextProject.groupName === 'Active') { + await projectDataProvider.toggleActiveProjects(Array.from(contextProject.projects), false); + } + } + + updateWorker(); + } + ); + + extensionContext.subscriptions.push(deactivateProjectCommand); + + const activatePhaseCommand = vscode.commands.registerCommand( + 'rush.activateProject', + async ( + contextPhase: Phase | PhaseStateGroup, + selectedPhases: (Phase | PhaseStateGroup)[] = [contextPhase] + ) => { + if (contextPhase instanceof Phase) { + await phaseDataProvider.toggleActivePhases(selectedPhases as Phase[], true); + } else if (contextPhase instanceof StateGroup) { + if (contextPhase.groupName === 'Included' || contextPhase.groupName === 'Available') { + await phaseDataProvider.toggleActivePhases(contextPhase.children, true); + } + } + + updateWorker(); + } + ); + + extensionContext.subscriptions.push(activatePhaseCommand); + + const deactivatePhaseCommand = vscode.commands.registerCommand( + 'rush.deactivateProject', + async ( + contextPhase: Phase | PhaseStateGroup, + selectedPhases: (Phase | PhaseStateGroup)[] = [contextPhase] + ) => { + if (contextPhase instanceof Phase) { + await phaseDataProvider.toggleActivePhases(selectedPhases as Phase[], false); + } else if (contextPhase instanceof StateGroup) { + if (contextPhase.groupName === 'Active') { + await phaseDataProvider.toggleActivePhases(contextPhase.children, false); + } + } + + updateWorker(); + } + ); + + extensionContext.subscriptions.push(deactivatePhaseCommand); + + const enableWatchCommand = vscode.commands.registerCommand('rush.enableWatch', async () => { + vscode.window.showInformationMessage('Initializing the Rush watcher.'); + vscode.commands.executeCommand('setContext', 'rush.watcher', 'starting'); + + statusBarItem.text = `$(sync~spin) Rush: initializing watcher`; + statusBarItem.show(); + + worker = new rush.PhasedCommandWorkerController(['--debug', 'start'], { + cwd: workspaceRoot, + onStatusUpdates: (statuses: Rush.ITransferableOperationStatus[]) => { + projectDataProvider.updateProjectPhases(statuses); + if (worker?.state === 'executing') { + const { activeOperationCount, pendingOperationCount } = worker; + statusBarItem.text = `$(sync~spin) Rush: running start (${ + activeOperationCount - pendingOperationCount + }/${activeOperationCount})`; + } + + const diagnosticsByPath: Map = new Map(); + for (const { diagnostics } of statuses) { + if (diagnostics?.length) { + for (const { file, line, column, message, severity, tag } of diagnostics) { + let collection = diagnosticsByPath.get(file); + if (!collection) { + diagnosticsByPath.set(file, (collection = [])); + } + const diagnostic = new vscode.Diagnostic( + new vscode.Range(line - 1, column - 1, line - 1, column), + `${tag} ${message}`, + severity === 'warning' ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Error + ); + collection.push(diagnostic); + } + } + } + + for (const [file, diagnostics] of diagnosticsByPath) { + rushDiagnostics.set(vscode.Uri.file(`${workspaceRoot}/${file}`), diagnostics); + } + }, + onStateChanged: (state: Rush.PhasedCommandWorkerState) => { + switch (state) { + case 'initializing': + statusBarItem.text = `$(sync~spin) Rush: initializing watcher`; + statusBarItem.show(); + break; + case 'updating': + statusBarItem.text = `$(sync~spin) Rush: detecting changes`; + break; + case 'waiting': + statusBarItem.text = `$(eye) Rush: watching`; + break; + case 'executing': + const { activeOperationCount, pendingOperationCount } = worker!; + statusBarItem.text = `$(sync~spin) Rush: running (${ + activeOperationCount - pendingOperationCount + }/${activeOperationCount})`; + break; + case 'exiting': + statusBarItem.text = `$(sync~spin) Rush: shutting down watcher`; + break; + case 'exited': + statusBarItem.hide(); + break; + } + } + }); + + try { + const graph: Rush.ITransferableOperation[] = await worker.getGraphAsync(); + + console.log(`Graph: `, graph); + projectDataProvider.updateProjectPhases( + graph.map((operation: Rush.ITransferableOperation) => { + return { + operation, + status: 'NO OP' as Rush.OperationStatus, + duration: 0, + hash: '', + active: false + }; + }) + ); + updateWorker(); + + vscode.window.showInformationMessage('Initialized the Rush watcher.'); + vscode.commands.executeCommand('setContext', 'rush.watcher', 'ready'); + } catch { + vscode.window.showErrorMessage('Failed to initialize the Rush watcher.'); + vscode.commands.executeCommand('setContext', 'rush.watcher', 'sleep'); + worker.shutdownAsync(true); + } + }); + + extensionContext.subscriptions.push(enableWatchCommand); + + const disableWatchCommand = vscode.commands.registerCommand('rush.disableWatch', async () => { + if (worker) { + vscode.window.showInformationMessage('Shutting down the Rush watcher.'); + try { + await worker.shutdownAsync(); + vscode.window.showInformationMessage('Shut down the Rush watcher.'); + } catch { + vscode.window.showErrorMessage('Failed to shut down the Rush watcher.'); + // Swallow error. + } + } + + statusBarItem.hide(); + + vscode.commands.executeCommand('setContext', 'rush.watcher', 'sleep'); + }); + + extensionContext.subscriptions.push(disableWatchCommand); + + extensionContext.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((e: vscode.TextDocument) => { + updateWorker(); + }) + ); + + await Promise.all([projectDataProvider.refresh(), phaseDataProvider.refresh()]); +} + +// this method is called when your extension is deactivated +export function deactivate() {} diff --git a/apps/vscode-extension/src/test/runTest.ts b/apps/vscode-extension/src/test/runTest.ts new file mode 100644 index 00000000000..014c3a28f7a --- /dev/null +++ b/apps/vscode-extension/src/test/runTest.ts @@ -0,0 +1,23 @@ +import * as path from 'path'; + +import { runTests } from '@vscode/test-electron'; + +async function main() { + try { + // The folder containing the Extension Manifest package.json + // Passed to `--extensionDevelopmentPath` + const extensionDevelopmentPath = path.resolve(__dirname, '../../'); + + // The path to test runner + // Passed to --extensionTestsPath + const extensionTestsPath = path.resolve(__dirname, './suite/index'); + + // Download VS Code, unzip it and run the integration test + await runTests({ extensionDevelopmentPath, extensionTestsPath }); + } catch (err) { + console.error('Failed to run tests'); + process.exit(1); + } +} + +main(); diff --git a/apps/vscode-extension/src/test/suite/extension.test.ts b/apps/vscode-extension/src/test/suite/extension.test.ts new file mode 100644 index 00000000000..17e2eab2ae2 --- /dev/null +++ b/apps/vscode-extension/src/test/suite/extension.test.ts @@ -0,0 +1,15 @@ +import * as assert from 'assert'; + +// You can import and use all API from the 'vscode' module +// as well as import your extension to test it +import * as vscode from 'vscode'; +// import * as myExtension from '../../extension'; + +suite('Extension Test Suite', () => { + vscode.window.showInformationMessage('Start all tests.'); + + test('Sample test', () => { + assert.strictEqual(-1, [1, 2, 3].indexOf(5)); + assert.strictEqual(-1, [1, 2, 3].indexOf(0)); + }); +}); diff --git a/apps/vscode-extension/src/test/suite/index.ts b/apps/vscode-extension/src/test/suite/index.ts new file mode 100644 index 00000000000..f584ab03928 --- /dev/null +++ b/apps/vscode-extension/src/test/suite/index.ts @@ -0,0 +1,38 @@ +import * as path from 'path'; +import * as Mocha from 'mocha'; +import * as glob from 'glob'; + +export function run(): Promise { + // Create the mocha test + const mocha = new Mocha({ + ui: 'tdd', + color: true + }); + + const testsRoot = path.resolve(__dirname, '..'); + + return new Promise((c, e) => { + glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { + if (err) { + return e(err); + } + + // Add files to the test suite + files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); + + try { + // Run the mocha test + mocha.run((failures) => { + if (failures > 0) { + e(new Error(`${failures} tests failed.`)); + } else { + c(); + } + }); + } catch (err) { + console.error(err); + e(err); + } + }); + }); +} diff --git a/apps/vscode-extension/tsconfig.json b/apps/vscode-extension/tsconfig.json new file mode 100644 index 00000000000..e5599fb204d --- /dev/null +++ b/apps/vscode-extension/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "esnext", + "moduleResolution": "node", + "target": "ES2020", + "lib": ["ES2020"], + "sourceMap": true, + "rootDir": "src", + "strict": true /* enable all strict type-checking options */ + /* Additional Checks */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + } +} diff --git a/apps/vscode-extension/vsc-extension-quickstart.md b/apps/vscode-extension/vsc-extension-quickstart.md new file mode 100644 index 00000000000..b2eb4a435ce --- /dev/null +++ b/apps/vscode-extension/vsc-extension-quickstart.md @@ -0,0 +1,47 @@ +# Welcome to your VS Code Extension + +## What's in the folder + +* This folder contains all of the files necessary for your extension. +* `package.json` - this is the manifest file in which you declare your extension and command. + * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. +* `src/extension.ts` - this is the main file where you will provide the implementation of your command. + * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. + * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. + +## Setup + +* install the recommended extensions (amodio.tsl-problem-matcher and dbaeumer.vscode-eslint) + + +## Get up and running straight away + +* Press `F5` to open a new window with your extension loaded. +* Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. +* Set breakpoints in your code inside `src/extension.ts` to debug your extension. +* Find output from your extension in the debug console. + +## Make changes + +* You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. +* You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. + + +## Explore the API + +* You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. + +## Run tests + +* Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown pick `Extension Tests`. +* Press `F5` to run the tests in a new window with your extension loaded. +* See the output of the test result in the debug console. +* Make changes to `src/test/suite/extension.test.ts` or create new test files inside the `test/suite` folder. + * The provided test runner will only consider files matching the name pattern `**.test.ts`. + * You can create folders inside the `test` folder to structure your tests any way you want. + +## Go further + +* Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). +* [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VS Code extension marketplace. +* Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). diff --git a/apps/vscode-extension/webpack.config.js b/apps/vscode-extension/webpack.config.js new file mode 100644 index 00000000000..f9de6813f81 --- /dev/null +++ b/apps/vscode-extension/webpack.config.js @@ -0,0 +1,53 @@ +//@ts-check + +'use strict'; + +const path = require('path'); +// eslint-disable-next-line @typescript-eslint/naming-convention +const { PreserveDynamicRequireWebpackPlugin } = require('@rushstack/webpack-preserve-dynamic-require-plugin'); + +//@ts-check +/** @typedef {import('webpack').Configuration} WebpackConfig **/ + +/** @type WebpackConfig */ +const extensionConfig = { + target: 'node', // VS Code extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ + mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') + + entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ + output: { + // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ + path: path.resolve(__dirname, 'dist'), + filename: 'extension.js', + libraryTarget: 'commonjs2' + }, + externals: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '@microsoft/rush-lib': 'commonjs @microsoft/rush-lib', + vscode: 'commonjs vscode' // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ + // modules added here also need to be added in the .vscodeignore file + }, + resolve: { + // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader + extensions: ['.ts', '.js'] + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: [ + { + loader: 'ts-loader' + } + ] + } + ] + }, + devtool: 'source-map', + infrastructureLogging: { + level: 'log' // enables logging required for problem matchers + }, + plugins: [new PreserveDynamicRequireWebpackPlugin()] +}; +module.exports = [extensionConfig]; diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 1c11b12bef0..0922411ddaa 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -2,6 +2,26 @@ { "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/approved-packages.schema.json", "packages": [ + { + "name": "@microsoft/rush", + "allowedCategories": [ "libraries" ] + }, + { + "name": "@rushstack/rush-lib", + "allowedCategories": [ "libraries" ] + }, + { + "name": "@rushstack/webpack-preserve-dynamic-require-plugin", + "allowedCategories": [ "libraries" ] + }, + { + "name": "@vscode/test-electron", + "allowedCategories": [ "libraries" ] + }, + { + "name": "mocha", + "allowedCategories": [ "libraries" ] + }, { "name": "react", "allowedCategories": [ "tests" ] diff --git a/common/config/rush/common-versions.json b/common/config/rush/common-versions.json index 47bcc551057..041eaecc26f 100644 --- a/common/config/rush/common-versions.json +++ b/common/config/rush/common-versions.json @@ -88,6 +88,8 @@ "style-loader": ["~2.0.0"], "terser-webpack-plugin": ["~3.0.8"], "terser": ["~4.8.0"], - "webpack": ["~4.44.2"] + "ts-loader": ["9.4.0"], + "webpack": ["~4.44.2"], + "webpack-cli": ["~4.10.0"] } } diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index abcd21c0205..c029450a645 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -676,7 +676,7 @@ }, { "name": "ts-loader", - "allowedCategories": [ "tests" ] + "allowedCategories": [ "libraries", "tests" ] }, { "name": "tslint", @@ -704,7 +704,7 @@ }, { "name": "webpack-cli", - "allowedCategories": [ "tests" ] + "allowedCategories": [ "libraries", "tests" ] }, { "name": "webpack-dev-server", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b4bca7613bf..8eea13010eb 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -192,6 +192,47 @@ importers: '@types/node': 12.20.24 '@types/semver': 7.3.5 + ../../apps/vscode-extension: + specifiers: + '@microsoft/rush': workspace:* + '@microsoft/rush-lib': workspace:* + '@rushstack/rush-sdk': workspace:* + '@rushstack/webpack-preserve-dynamic-require-plugin': workspace:* + '@types/glob': 7.1.1 + '@types/mocha': ^9.1.1 + '@types/node': 12.20.24 + '@types/vscode': ^1.71.0 + '@typescript-eslint/eslint-plugin': ~5.30.3 + '@typescript-eslint/parser': ~5.30.3 + '@vscode/test-electron': ^2.1.5 + eslint: ~8.7.0 + glob: ~7.0.5 + mocha: ^10.0.0 + ts-loader: 9.4.0 + typescript: ~4.7.4 + webpack: ~5.68.0 + webpack-cli: ~4.10.0 + dependencies: + '@rushstack/rush-sdk': link:../../libraries/rush-sdk + devDependencies: + '@microsoft/rush': link:../rush + '@microsoft/rush-lib': link:../../libraries/rush-lib + '@rushstack/webpack-preserve-dynamic-require-plugin': link:../../webpack/preserve-dynamic-require-plugin + '@types/glob': 7.1.1 + '@types/mocha': 9.1.1 + '@types/node': 12.20.24 + '@types/vscode': 1.71.0 + '@typescript-eslint/eslint-plugin': 5.30.3_exlp6dxqua5sxvf2mpdocyqr3m + '@typescript-eslint/parser': 5.30.3_valmiib6gbzc7jhcbpocdsabay + '@vscode/test-electron': 2.1.5 + eslint: 8.7.0 + glob: 7.0.6 + mocha: 10.0.0 + ts-loader: 9.4.0_y6z6q2r6y5t2cxg26ednarkyyq + typescript: 4.7.4 + webpack: 5.68.0_webpack-cli@4.10.0 + webpack-cli: 4.10.0_webpack@5.68.0 + ../../build-tests-samples/heft-node-basic-tutorial: specifiers: '@rushstack/eslint-config': workspace:* @@ -7080,6 +7121,10 @@ packages: '@types/node': 12.20.24 dev: true + /@types/mocha/9.1.1: + resolution: {integrity: sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==} + dev: true + /@types/node-fetch/1.6.9: resolution: {integrity: sha512-n2r6WLoY7+uuPT7pnEtKJCmPUGyJ+cbyBR8Avnu4+m1nzz7DwBVuyIvvlBzCZ/nrpC7rIgb3D6pNavL7rFEa9g==} dependencies: @@ -7268,6 +7313,10 @@ packages: resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} dev: true + /@types/vscode/1.71.0: + resolution: {integrity: sha512-nB50bBC9H/x2CpwW9FzRRRDrTZ7G0/POttJojvN/LiVfzTGfLyQIje1L1QRMdFXK9G41k5UJN/1B9S4of7CSzA==} + dev: true + /@types/webpack-env/1.13.0: resolution: {integrity: sha512-0BANcVFVqkAD1i7/fWy9Vu6KjB9whuAmkfFX0GFwNzubu2i0qXDsLvGZSbU1QimJHWH4rqjJDQ/PX9v5OVepEA==} @@ -7344,7 +7393,6 @@ packages: typescript: 4.7.4 transitivePeerDependencies: - supports-color - dev: false /@typescript-eslint/eslint-plugin/5.30.3_wnypca4zi2m2ykuw256e4kq76m: resolution: {integrity: sha512-QEgE1uahnDbWEkZlidq7uKB630ny1NN8KbLPmznX+8hYsYpoV1/quG1Nzvs141FVuumuS7O0EpqYw3RB4AVzRg==} @@ -7483,7 +7531,6 @@ packages: typescript: 4.7.4 transitivePeerDependencies: - supports-color - dev: false /@typescript-eslint/types/5.30.3_typescript@4.7.4: resolution: {integrity: sha512-vshU3pjSTgBPNgfd55JLYngHkXuwQP68fxYFUAg1Uq+JrR3xG/XjvL9Dmv28CpOERtqwkaR4QQ3mD0NLZcE2Xw==} @@ -7547,7 +7594,6 @@ packages: transitivePeerDependencies: - supports-color - typescript - dev: false /@typescript-eslint/visitor-keys/5.30.3_typescript@4.7.4: resolution: {integrity: sha512-ep2xtHOhnSRt6fDP9DSSxrA/FqZhdMF7/Y9fYsxrKss2uWJMbzJyBJ/We1fKc786BJ10pHwrzUlhvpz8i7XzBg==} @@ -7558,6 +7604,22 @@ packages: transitivePeerDependencies: - typescript + /@ungap/promise-all-settled/1.1.2: + resolution: {integrity: sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==} + dev: true + + /@vscode/test-electron/2.1.5: + resolution: {integrity: sha512-O/ioqFpV+RvKbRykX2ItYPnbcZ4Hk5V0rY4uhQjQTLhGL9WZUvS7exzuYQCCI+ilSqJpctvxq2llTfGXf9UnnA==} + engines: {node: '>=8.9.3'} + dependencies: + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.1 + rimraf: 3.0.2 + unzipper: 0.10.11 + transitivePeerDependencies: + - supports-color + dev: true + /@webassemblyjs/ast/1.11.1: resolution: {integrity: sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==} dependencies: @@ -7758,6 +7820,37 @@ packages: '@webassemblyjs/wast-parser': 1.9.0 '@xtuc/long': 4.2.2 + /@webpack-cli/configtest/1.2.0_rwfyai4fxq6tnmgv4kfhytp6ay: + resolution: {integrity: sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==} + peerDependencies: + webpack: 4.x.x || 5.x.x + webpack-cli: 4.x.x + dependencies: + webpack: 5.68.0_webpack-cli@4.10.0 + webpack-cli: 4.10.0_webpack@5.68.0 + dev: true + + /@webpack-cli/info/1.5.0_webpack-cli@4.10.0: + resolution: {integrity: sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==} + peerDependencies: + webpack-cli: 4.x.x + dependencies: + envinfo: 7.8.1 + webpack-cli: 4.10.0_webpack@5.68.0 + dev: true + + /@webpack-cli/serve/1.7.0_webpack-cli@4.10.0: + resolution: {integrity: sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==} + peerDependencies: + webpack-cli: 4.x.x + webpack-dev-server: '*' + peerDependenciesMeta: + webpack-dev-server: + optional: true + dependencies: + webpack-cli: 4.10.0_webpack@5.68.0 + dev: true + /@xtuc/ieee754/1.2.0: resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -7965,6 +8058,11 @@ packages: engines: {node: '>=6'} dev: true + /ansi-colors/4.1.1: + resolution: {integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==} + engines: {node: '>=6'} + dev: true + /ansi-colors/4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -8703,6 +8801,11 @@ packages: is-windows: 1.0.2 dev: false + /big-integer/1.6.51: + resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} + engines: {node: '>=0.6'} + dev: true + /big.js/3.2.0: resolution: {integrity: sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==} dev: false @@ -8719,6 +8822,13 @@ packages: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} + /binary/0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + dev: true + /bindings/1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} requiresBuild: true @@ -8734,6 +8844,10 @@ packages: readable-stream: 3.6.0 dev: true + /bluebird/3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + dev: true + /bluebird/3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -8798,6 +8912,12 @@ packages: balanced-match: 1.0.2 concat-map: 0.0.1 + /brace-expansion/2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + /braces/2.3.2: resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==} engines: {node: '>=0.10.0'} @@ -8825,6 +8945,10 @@ packages: /browser-process-hrtime/1.0.0: resolution: {integrity: sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==} + /browser-stdout/1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + dev: true + /browserify-aes/1.2.0: resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} dependencies: @@ -8901,6 +9025,11 @@ packages: /buffer-from/1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + /buffer-indexof-polyfill/1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + dev: true + /buffer-xor/1.0.3: resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==} @@ -8918,6 +9047,11 @@ packages: ieee754: 1.2.1 dev: true + /buffers/0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + dev: true + /builtin-modules/1.1.1: resolution: {integrity: sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ==} engines: {node: '>=0.10.0'} @@ -9129,6 +9263,12 @@ packages: yargs: 16.2.0 dev: true + /chainsaw/0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + dependencies: + traverse: 0.3.9 + dev: true + /chalk/1.1.3: resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} engines: {node: '>=0.10.0'} @@ -9526,7 +9666,7 @@ packages: dev: true /concat-map/0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} /concat-stream/1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -10068,6 +10208,19 @@ packages: dependencies: ms: 2.1.2 + /debug/4.3.4_supports-color@8.1.1: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + supports-color: 8.1.1 + dev: true + /debuglog/1.0.1: resolution: {integrity: sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==} dev: false @@ -10084,6 +10237,11 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + /decamelize/4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + dev: true + /decamelize/5.0.1: resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==} engines: {node: '>=10'} @@ -10413,6 +10571,12 @@ packages: /duplexer/0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + /duplexer2/0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + dependencies: + readable-stream: 2.3.7 + dev: true + /duplexer3/0.1.4: resolution: {integrity: sha512-CEj8FwwNA4cVH2uFCoHUrmojhYh1vmCdOaneKJXwkeY1i9jnlslVo9dx+hQ5Hl9GnH/Bwy/IjxAyOePyPKYnzA==} dev: true @@ -11531,6 +11695,11 @@ packages: /fast-safe-stringify/2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + /fastest-levenshtein/1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + dev: true + /fastify-error/0.3.1: resolution: {integrity: sha512-oCfpcsDndgnDVgiI7bwFKAun2dO+4h84vBlkWsWnz/OUK9Reff5UFoFl241xTiLeHWX/vU9zkDVXqYUxjOwHcQ==} dev: false @@ -11747,6 +11916,11 @@ packages: flatted: 3.2.5 rimraf: 3.0.2 + /flat/5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + dev: true + /flatstr/1.0.12: resolution: {integrity: sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==} dev: false @@ -11974,6 +12148,16 @@ packages: requiresBuild: true optional: true + /fstream/1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + dependencies: + graceful-fs: 4.2.10 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + dev: true + /ftp/0.3.10: resolution: {integrity: sha512-faFVML1aBx2UoDStmLwv2Wptt4vw5x03xxX172nhA5Y5HBshW5JweqQ2W4xL4dezQTG8inJsuYcpPHHU3X5OTQ==} engines: {node: '>=0.8.0'} @@ -12192,6 +12376,17 @@ packages: path-is-absolute: 1.0.1 dev: false + /glob/7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + /glob/7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} dependencies: @@ -13348,6 +13543,11 @@ packages: /is-typedarray/1.0.0: resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + /is-unicode-supported/0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + dev: true + /is-weakref/1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} dependencies: @@ -14474,6 +14674,10 @@ packages: /lines-and-columns/1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + /listenercount/1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + dev: true + /load-json-file/6.2.0: resolution: {integrity: sha512-gUD/epcRms75Cw8RT1pUdHugZYM5ce64ucs2GEISABwkRsOQr0q2wm/MV2TKThycIe5e0ytRweW2RZxclogCdQ==} engines: {node: '>=8'} @@ -14620,6 +14824,14 @@ packages: /lodash/4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + /log-symbols/4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + dev: true + /log4js/6.5.2: resolution: {integrity: sha512-DXtpNtt+KDOMT7RHUDIur/WsSA3rntlUh9Zg4XCdV42wUuMmbFkl38+LZ92Z5QvQA7mD5kAVkLiBSEH/tvUB8A==} engines: {node: '>=8.0'} @@ -14993,6 +15205,13 @@ packages: dependencies: brace-expansion: 1.1.11 + /minimatch/5.0.1: + resolution: {integrity: sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options/4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -15090,6 +15309,35 @@ packages: engines: {node: '>=10'} hasBin: true + /mocha/10.0.0: + resolution: {integrity: sha512-0Wl+elVUD43Y0BqPZBzZt8Tnkw9CMUdNYnUsTfOM1vuhJVZL+kiesFYsqwBkEEuEixaiPe5ZQdqDgX2jddhmoA==} + engines: {node: '>= 14.0.0'} + hasBin: true + dependencies: + '@ungap/promise-all-settled': 1.1.2 + ansi-colors: 4.1.1 + browser-stdout: 1.3.1 + chokidar: 3.5.3 + debug: 4.3.4_supports-color@8.1.1 + diff: 5.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 7.2.0 + he: 1.2.0 + js-yaml: 4.1.0 + log-symbols: 4.1.0 + minimatch: 5.0.1 + ms: 2.1.3 + nanoid: 3.3.3 + serialize-javascript: 6.0.0 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.2.1 + yargs: 16.2.0 + yargs-parser: 20.2.4 + yargs-unparser: 2.0.0 + dev: true + /move-concurrently/1.0.1: resolution: {integrity: sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==} dependencies: @@ -15138,6 +15386,12 @@ packages: /nan/2.16.0: resolution: {integrity: sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA==} + /nanoid/3.3.3: + resolution: {integrity: sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + /nanoid/3.3.4: resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -17168,6 +17422,13 @@ packages: resolve: 1.17.0 dev: true + /rechoir/0.7.1: + resolution: {integrity: sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==} + engines: {node: '>= 0.10'} + dependencies: + resolve: 1.17.0 + dev: true + /redent/3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -18914,6 +19175,10 @@ packages: dependencies: punycode: 2.1.1 + /traverse/0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + dev: true + /trim-newlines/3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} @@ -18959,6 +19224,21 @@ packages: typescript: 4.7.4 dev: false + /ts-loader/9.4.0_y6z6q2r6y5t2cxg26ednarkyyq: + resolution: {integrity: sha512-0G3UMhk1bjgsgiwF4rnZRAeTi69j9XMDtmDDMghGSqlWESIAS3LFgJe//GYfE4vcjbyzuURLB9Us2RZIWp2clQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + typescript: '*' + webpack: ^5.0.0 + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.9.3 + micromatch: 4.0.5 + semver: 7.3.7 + typescript: 4.7.4 + webpack: 5.68.0_webpack-cli@4.10.0 + dev: true + /ts-pnp/1.2.0_typescript@4.7.4: resolution: {integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==} engines: {node: '>=6'} @@ -19294,6 +19574,21 @@ packages: has-value: 0.3.1 isobject: 3.0.1 + /unzipper/0.10.11: + resolution: {integrity: sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw==} + dependencies: + big-integer: 1.6.51 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.10 + listenercount: 1.0.1 + readable-stream: 2.3.7 + setimmediate: 1.0.5 + dev: true + /upath/1.2.0: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} @@ -19654,6 +19949,41 @@ packages: yargs: 13.3.2 dev: false + /webpack-cli/4.10.0_webpack@5.68.0: + resolution: {integrity: sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + '@webpack-cli/generators': '*' + '@webpack-cli/migrate': '*' + webpack: 4.x.x || 5.x.x + webpack-bundle-analyzer: '*' + webpack-dev-server: '*' + peerDependenciesMeta: + '@webpack-cli/generators': + optional: true + '@webpack-cli/migrate': + optional: true + webpack-bundle-analyzer: + optional: true + webpack-dev-server: + optional: true + dependencies: + '@discoveryjs/json-ext': 0.5.7 + '@webpack-cli/configtest': 1.2.0_rwfyai4fxq6tnmgv4kfhytp6ay + '@webpack-cli/info': 1.5.0_webpack-cli@4.10.0 + '@webpack-cli/serve': 1.7.0_webpack-cli@4.10.0 + colorette: 2.0.19 + commander: 7.2.0 + cross-spawn: 7.0.3 + fastest-levenshtein: 1.0.16 + import-local: 3.1.0 + interpret: 2.2.0 + rechoir: 0.7.1 + webpack: 5.68.0_webpack-cli@4.10.0 + webpack-merge: 5.8.0 + dev: true + /webpack-dev-middleware/3.7.3_2jhnw6fokymnjfoumvhvkjoyjq: resolution: {integrity: sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==} engines: {node: '>= 6'} @@ -19919,7 +20249,6 @@ packages: dependencies: clone-deep: 4.0.1 wildcard: 2.0.0 - dev: false /webpack-sources/1.4.3: resolution: {integrity: sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==} @@ -20052,6 +20381,47 @@ packages: - esbuild - uglify-js + /webpack/5.68.0_webpack-cli@4.10.0: + resolution: {integrity: sha512-zUcqaUO0772UuuW2bzaES2Zjlm/y3kRBQDVFVCge+s2Y8mwuUTdperGaAv65/NtRL/1zanpSJOq/MD8u61vo6g==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + dependencies: + '@types/eslint-scope': 3.7.3 + '@types/estree': 0.0.50 + '@webassemblyjs/ast': 1.11.1 + '@webassemblyjs/wasm-edit': 1.11.1 + '@webassemblyjs/wasm-parser': 1.11.1 + acorn: 8.7.1 + acorn-import-assertions: 1.8.0_acorn@8.7.1 + browserslist: 4.20.4 + chrome-trace-event: 1.0.3 + enhanced-resolve: 5.9.3 + es-module-lexer: 0.9.3 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.10 + json-parse-better-errors: 1.0.2 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.1.1 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.3_webpack@5.68.0 + watchpack: 2.4.0 + webpack-cli: 4.10.0_webpack@5.68.0 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + dev: true + /websocket-driver/0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} engines: {node: '>=0.8.0'} @@ -20125,7 +20495,6 @@ packages: /wildcard/2.0.0: resolution: {integrity: sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==} - dev: false /word-wrap/1.2.3: resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} @@ -20145,6 +20514,10 @@ packages: microevent.ts: 0.1.1 dev: true + /workerpool/6.2.1: + resolution: {integrity: sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==} + dev: true + /wrap-ansi/5.1.0: resolution: {integrity: sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==} engines: {node: '>=6'} @@ -20316,10 +20689,25 @@ packages: decamelize: 1.2.0 dev: true + /yargs-parser/20.2.4: + resolution: {integrity: sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==} + engines: {node: '>=10'} + dev: true + /yargs-parser/20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} + /yargs-unparser/2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + dev: true + /yargs/13.3.2: resolution: {integrity: sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==} dependencies: diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index a8d1d5b071f..eae34bf678e 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "9f318b7f81c50e7dd8f20cb296676b3e05f6a40b", + "pnpmShrinkwrapHash": "0287692314f45d724adc9578462265497be117a5", "preferredVersionsHash": "d15f901c51f5b82ae0cc4cc0a2cdc9a5e451367c" } diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index b7567025f1c..e4563b6ea6d 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -11,11 +11,14 @@ import { AsyncSeriesHook } from 'tapable'; import { AsyncSeriesWaterfallHook } from 'tapable'; import type { CollatedWriter } from '@rushstack/stream-collator'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; +import { CommandLineParser } from '@rushstack/ts-command-line'; import { HookMap } from 'tapable'; +import { IDiagnostic } from '@rushstack/terminal'; import { IPackageJson } from '@rushstack/node-core-library'; import { ITerminal } from '@rushstack/node-core-library'; import { ITerminalProvider } from '@rushstack/node-core-library'; import { JsonObject } from '@rushstack/node-core-library'; +import { JsonSchema } from '@rushstack/node-core-library'; import { PackageNameParser } from '@rushstack/node-core-library'; import type { StdioSummarizer } from '@rushstack/terminal'; import { SyncHook } from 'tapable'; @@ -283,6 +286,8 @@ export interface ICredentialCacheOptions { supportEditing: boolean; } +export { IDiagnostic } + // @beta (undocumented) export interface IEnvironmentConfigurationInitializeOptions { // (undocumented) @@ -372,6 +377,7 @@ export interface _INpmOptionsJson extends IPackageManagerOptionsJsonBase { // @alpha export interface IOperationExecutionResult { readonly error: Error | undefined; + readonly silent: boolean; readonly status: OperationStatus; readonly stdioSummarizer: StdioSummarizer; readonly stopwatch: IStopwatchResult; @@ -399,8 +405,11 @@ export interface IOperationRunner { export interface IOperationRunnerContext { collatedWriter: CollatedWriter; debugMode: boolean; + isCacheWriteAllowed: boolean; quietMode: boolean; + stateHash: string | undefined; stdioSummarizer: StdioSummarizer; + trackedFileHashes: ReadonlyMap | undefined; } // @public @@ -428,6 +437,13 @@ export interface IPhasedCommand extends IRushCommand { readonly hooks: PhasedCommandHooks; } +// @alpha (undocumented) +export interface IPhasedCommandWorkerOptions { + cwd?: string; + onStateChanged?: (state: PhasedCommandWorkerState) => void; + onStatusUpdates?: (operationStatus: ITransferableOperationStatus[]) => void; +} + // @internal export interface _IPnpmOptionsJson extends IPackageManagerOptionsJsonBase { pnpmStore?: PnpmStoreOptions; @@ -504,6 +520,34 @@ export interface ITelemetryOperationResult { startTimestampMs?: number; } +// @alpha (undocumented) +export interface ITransferableOperation { + // (undocumented) + logFilePath?: string; + // (undocumented) + name?: string; + // (undocumented) + phase?: string; + // (undocumented) + project?: string; +} + +// @alpha (undocumented) +export interface ITransferableOperationStatus { + // (undocumented) + active: boolean; + // (undocumented) + diagnostics?: IDiagnostic[]; + // (undocumented) + duration: number; + // (undocumented) + hash: string | undefined; + // (undocumented) + operation: ITransferableOperation; + // (undocumented) + status: OperationStatus; +} + // @public export interface ITryFindRushJsonLocationOptions { showVerbose?: boolean; @@ -654,6 +698,30 @@ export class PhasedCommandHooks { readonly waitingForChanges: SyncHook; } +// @alpha +export class PhasedCommandWorkerController { + constructor(args: string[], options?: IPhasedCommandWorkerOptions); + abort(): void; + // (undocumented) + get activeOperationCount(): number; + getGraph(): ITransferableOperation[]; + // (undocumented) + getGraphAsync(): Promise; + // (undocumented) + getStatuses(): Iterable; + onStateChanged: (state: PhasedCommandWorkerState) => void; + onStatusUpdates: (statuses: ITransferableOperationStatus[]) => void; + // (undocumented) + get pendingOperationCount(): number; + shutdownAsync(force?: boolean): Promise; + // (undocumented) + get state(): PhasedCommandWorkerState; + update(operations: ITransferableOperation[]): void; +} + +// @alpha (undocumented) +export type PhasedCommandWorkerState = 'initializing' | 'waiting' | 'updating' | 'executing' | 'exiting' | 'exited'; + // @public export class PnpmOptionsConfiguration extends PackageManagerOptionsConfigurationBase { // @internal @@ -678,6 +746,10 @@ export class ProjectChangeAnalyzer { // (undocumented) _filterProjectDataAsync(project: RushConfigurationProject, unfilteredProjectData: Map, rootDir: string, terminal: ITerminal): Promise>; getChangedProjectsAsync(options: IGetChangedProjectsOptions): Promise>; + // @internal (undocumented) + _getOperationStateHash(localHash: string, dependencyHashes: string[]): string; + // @internal (undocumented) + _hashProjectDependencies(packageDeps: Map): string; // @internal _tryGetProjectDependenciesAsync(project: RushConfigurationProject, terminal: ITerminal): Promise | undefined>; // @internal @@ -702,6 +774,43 @@ export class Rush { static get version(): string; } +// @public (undocumented) +export class RushCommandLineParser extends CommandLineParser { + // Warning: (ae-forgotten-export) The symbol "IRushCommandLineParserOptions" needs to be exported by the entry point index.d.ts + constructor(options?: Partial); + // (undocumented) + execute(args?: string[]): Promise; + // (undocumented) + flushTelemetry(): void; + // (undocumented) + get isDebug(): boolean; + // (undocumented) + get isQuiet(): boolean; + // (undocumented) + protected onDefineParameters(): void; + // (undocumented) + protected onExecute(): Promise; + // Warning: (ae-forgotten-export) The symbol "PluginManager" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly pluginManager: PluginManager; + // (undocumented) + readonly rushConfiguration: RushConfiguration; + // Warning: (ae-incompatible-release-tags) The symbol "rushGlobalFolder" is marked as @public, but its signature references "_RushGlobalFolder" which is marked as @internal + // + // (undocumented) + rushGlobalFolder: _RushGlobalFolder; + // Warning: (ae-incompatible-release-tags) The symbol "rushSession" is marked as @public, but its signature references "RushSession" which is marked as @beta + // + // (undocumented) + readonly rushSession: RushSession; + static shouldRestrictConsoleOutput(): boolean; + // Warning: (ae-forgotten-export) The symbol "Telemetry" needs to be exported by the entry point index.d.ts + // + // (undocumented) + telemetry: Telemetry | undefined; +} + // @public export class RushConfiguration { get allowMostlyStandardPackageNames(): boolean; diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index 31a3d8f656b..d9c9b39aedd 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -31,6 +31,28 @@ export interface ICallbackWritableOptions { onWriteChunk: (chunk: ITerminalChunk) => void; } +// @alpha (undocumented) +export interface IDiagnostic { + // (undocumented) + column: number; + // (undocumented) + file: string; + // (undocumented) + line: number; + // (undocumented) + message: string; + // (undocumented) + severity: 'error' | 'warning'; + // (undocumented) + tag?: string; +} + +// @alpha (undocumented) +export interface IDiagnosticMatcher { + // (undocumented) + (line: string): IDiagnostic | undefined; +} + // @beta export interface IDiscardStdoutTransformOptions extends ITerminalTransformOptions { } @@ -53,6 +75,8 @@ export interface IStdioLineTransformOptions extends ITerminalTransformOptions { // @beta export interface IStdioSummarizerOptions { + // Warning: (ae-incompatible-release-tags) The symbol "diagnosticMatcher" is marked as @beta, but its signature references "IDiagnosticMatcher" which is marked as @alpha + diagnosticMatcher?: IDiagnosticMatcher; leadingLines?: number; trailingLines?: number; } @@ -152,6 +176,10 @@ export class StderrLineTransform extends TerminalTransform { // @beta export class StdioSummarizer extends TerminalWritable { constructor(options?: IStdioSummarizerOptions); + // Warning: (ae-incompatible-release-tags) The symbol "diagnostics" is marked as @beta, but its signature references "IDiagnostic" which is marked as @alpha + // + // (undocumented) + get diagnostics(): ReadonlyArray; getReport(): string; // (undocumented) onWriteChunk(chunk: ITerminalChunk): void; diff --git a/libraries/package-deps-hash/src/getRepoState.ts b/libraries/package-deps-hash/src/getRepoState.ts index 3d99554224b..e78ec7f1a88 100644 --- a/libraries/package-deps-hash/src/getRepoState.ts +++ b/libraries/package-deps-hash/src/getRepoState.ts @@ -215,7 +215,7 @@ export function applyWorkingTreeState( const hashObjectResult: child_process.SpawnSyncReturns = Executable.spawnSync( gitPath || 'git', ['hash-object', '--stdin-paths'], - { input: filesToHash.join('\n') } + { input: filesToHash.join('\n'), currentWorkingDirectory: rootDirectory } ); if (hashObjectResult.status !== 0) { diff --git a/libraries/rush-lib/src/cli/DefaultActions.ts b/libraries/rush-lib/src/cli/DefaultActions.ts new file mode 100644 index 00000000000..a53c6bd4a93 --- /dev/null +++ b/libraries/rush-lib/src/cli/DefaultActions.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { RushCommandLineParser } from './RushCommandLineParser'; + +import { AddAction } from './actions/AddAction'; +import { ChangeAction } from './actions/ChangeAction'; +import { CheckAction } from './actions/CheckAction'; +import { DeployAction } from './actions/DeployAction'; +import { InitAction } from './actions/InitAction'; +import { InitAutoinstallerAction } from './actions/InitAutoinstallerAction'; +import { InitDeployAction } from './actions/InitDeployAction'; +import { InstallAction } from './actions/InstallAction'; +import { LinkAction } from './actions/LinkAction'; +import { ListAction } from './actions/ListAction'; +import { PublishAction } from './actions/PublishAction'; +import { PurgeAction } from './actions/PurgeAction'; +import { ScanAction } from './actions/ScanAction'; +import { SetupAction } from './actions/SetupAction'; +import { UnlinkAction } from './actions/UnlinkAction'; +import { UpdateAction } from './actions/UpdateAction'; +import { UpdateAutoinstallerAction } from './actions/UpdateAutoinstallerAction'; +import { VersionAction } from './actions/VersionAction'; +import { UpdateCloudCredentialsAction } from './actions/UpdateCloudCredentialsAction'; + +export function addDefaultRushActions(parser: RushCommandLineParser): void { + // Alphabetical order + parser.addAction(new AddAction(parser)); + parser.addAction(new ChangeAction(parser)); + parser.addAction(new CheckAction(parser)); + parser.addAction(new DeployAction(parser)); + parser.addAction(new InitAction(parser)); + parser.addAction(new InitAutoinstallerAction(parser)); + parser.addAction(new InitDeployAction(parser)); + parser.addAction(new InstallAction(parser)); + parser.addAction(new LinkAction(parser)); + parser.addAction(new ListAction(parser)); + parser.addAction(new PublishAction(parser)); + parser.addAction(new PurgeAction(parser)); + parser.addAction(new ScanAction(parser)); + parser.addAction(new SetupAction(parser)); + parser.addAction(new UnlinkAction(parser)); + parser.addAction(new UpdateAction(parser)); + parser.addAction(new UpdateAutoinstallerAction(parser)); + parser.addAction(new UpdateCloudCredentialsAction(parser)); + parser.addAction(new VersionAction(parser)); +} diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 22160b68454..c4d13e9e51b 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -23,32 +23,12 @@ import { IPhasedCommandConfig } from '../api/CommandLineConfiguration'; -import { AddAction } from './actions/AddAction'; -import { ChangeAction } from './actions/ChangeAction'; -import { CheckAction } from './actions/CheckAction'; -import { DeployAction } from './actions/DeployAction'; -import { InitAction } from './actions/InitAction'; -import { InitAutoinstallerAction } from './actions/InitAutoinstallerAction'; -import { InitDeployAction } from './actions/InitDeployAction'; -import { InstallAction } from './actions/InstallAction'; -import { LinkAction } from './actions/LinkAction'; -import { ListAction } from './actions/ListAction'; -import { PublishAction } from './actions/PublishAction'; -import { PurgeAction } from './actions/PurgeAction'; -import { ScanAction } from './actions/ScanAction'; -import { UnlinkAction } from './actions/UnlinkAction'; -import { UpdateAction } from './actions/UpdateAction'; -import { UpdateAutoinstallerAction } from './actions/UpdateAutoinstallerAction'; -import { VersionAction } from './actions/VersionAction'; -import { UpdateCloudCredentialsAction } from './actions/UpdateCloudCredentialsAction'; - import { GlobalScriptAction } from './scriptActions/GlobalScriptAction'; import { IBaseScriptActionOptions } from './scriptActions/BaseScriptAction'; import { Telemetry } from '../logic/Telemetry'; import { RushGlobalFolder } from '../api/RushGlobalFolder'; import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; -import { SetupAction } from './actions/SetupAction'; import { ICustomCommandLineConfigurationInfo, PluginManager } from '../pluginFramework/PluginManager'; import { RushSession } from '../pluginFramework/RushSession'; import { PhasedScriptAction } from './scriptActions/PhasedScriptAction'; @@ -61,6 +41,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + excludeDefaultActions: boolean; } export class RushCommandLineParser extends CommandLineParser { @@ -222,7 +203,8 @@ export class RushCommandLineParser extends CommandLineParser { return { cwd: options.cwd || process.cwd(), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, - builtInPluginConfigurations: options.builtInPluginConfigurations || [] + builtInPluginConfigurations: options.builtInPluginConfigurations || [], + excludeDefaultActions: options.excludeDefaultActions || false }; } @@ -241,26 +223,10 @@ export class RushCommandLineParser extends CommandLineParser { try { this.rushGlobalFolder = new RushGlobalFolder(); - // Alphabetical order - this.addAction(new AddAction(this)); - this.addAction(new ChangeAction(this)); - this.addAction(new CheckAction(this)); - this.addAction(new DeployAction(this)); - this.addAction(new InitAction(this)); - this.addAction(new InitAutoinstallerAction(this)); - this.addAction(new InitDeployAction(this)); - this.addAction(new InstallAction(this)); - this.addAction(new LinkAction(this)); - this.addAction(new ListAction(this)); - this.addAction(new PublishAction(this)); - this.addAction(new PurgeAction(this)); - this.addAction(new ScanAction(this)); - this.addAction(new SetupAction(this)); - this.addAction(new UnlinkAction(this)); - this.addAction(new UpdateAction(this)); - this.addAction(new UpdateAutoinstallerAction(this)); - this.addAction(new UpdateCloudCredentialsAction(this)); - this.addAction(new VersionAction(this)); + if (!this._rushOptions.excludeDefaultActions) { + const { addDefaultRushActions }: typeof import('./DefaultActions') = require('./DefaultActions'); + addDefaultRushActions(this); + } this._populateScriptActions(); } catch (error) { diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 58257eb462e..06a241899c3 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -34,9 +34,13 @@ import { ShellOperationRunnerPlugin } from '../../logic/operations/ShellOperatio import { Event } from '../../api/EventHooks'; import { ProjectChangeAnalyzer } from '../../logic/ProjectChangeAnalyzer'; import { OperationStatus } from '../../logic/operations/OperationStatus'; -import { IExecutionResult } from '../../logic/operations/IOperationExecutionResult'; +import { + IExecutionResult, + IOperationExecutionResult +} from '../../logic/operations/IOperationExecutionResult'; import { OperationResultSummarizerPlugin } from '../../logic/operations/OperationResultSummarizerPlugin'; import type { ITelemetryOperationResult } from '../../logic/Telemetry'; +import { IAbortSignal } from '../../logic/operations/AsyncOperationQueue'; /** * Constructor parameters for PhasedScriptAction. @@ -63,13 +67,14 @@ interface IRunPhasesOptions { terminal: Terminal; } -interface IExecutionOperationsOptions { +export interface IExecuteOperationsOptions { createOperationsContext: ICreateOperationsContext; executionManagerOptions: IOperationExecutionManagerOptions; ignoreHooks: boolean; operations: Set; stopwatch: Stopwatch; terminal: Terminal; + abortSignal?: IAbortSignal; } interface IPhasedCommandTelemetry { @@ -278,7 +283,7 @@ export class PhasedScriptAction extends BaseScriptAction { initialCreateOperationsContext ); - const initialOptions: IExecutionOperationsOptions = { + const initialOptions: IExecuteOperationsOptions = { createOperationsContext: initialCreateOperationsContext, ignoreHooks: false, operations, @@ -362,7 +367,7 @@ export class PhasedScriptAction extends BaseScriptAction { createOperationsContext ); - const executeOptions: IExecutionOperationsOptions = { + const executeOptions: IExecuteOperationsOptions = { createOperationsContext, // For now, don't run pre-build or post-build in watch mode ignoreHooks: true, @@ -476,25 +481,34 @@ export class PhasedScriptAction extends BaseScriptAction { /** * Runs a set of operations and reports the results. + * @internal */ - private async _executeOperations(options: IExecutionOperationsOptions): Promise { - const { executionManagerOptions, ignoreHooks, operations, stopwatch, terminal } = options; + public async _executeOperations(options: IExecuteOperationsOptions): Promise { + const { + createOperationsContext, + executionManagerOptions, + ignoreHooks, + operations, + stopwatch, + abortSignal, + terminal + } = options; const executionManager: OperationExecutionManager = new OperationExecutionManager( operations, executionManagerOptions ); - const { isInitial, isWatch } = options.createOperationsContext; + const { isInitial, isWatch, projectChangeAnalyzer } = createOperationsContext; let success: boolean = false; let result: IExecutionResult | undefined; try { - result = await executionManager.executeAsync(); + result = await executionManager.executeAsync(projectChangeAnalyzer, abortSignal); success = result.status === OperationStatus.Success; - await this.hooks.afterExecuteOperations.promise(result, options.createOperationsContext); + await this.hooks.afterExecuteOperations.promise(result, createOperationsContext); stopwatch.stop(); @@ -549,6 +563,7 @@ export class PhasedScriptAction extends BaseScriptAction { }; if (result) { + const { operationResults: rawOperationResults } = result; const nonSilentDependenciesByOperation: Map> = new Map(); function getNonSilentDependencies(operation: Operation): ReadonlySet { let realDependencies: Set | undefined = nonSilentDependenciesByOperation.get(operation); @@ -556,7 +571,8 @@ export class PhasedScriptAction extends BaseScriptAction { realDependencies = new Set(); nonSilentDependenciesByOperation.set(operation, realDependencies); for (const dependency of operation.dependencies) { - if (dependency.runner!.silent) { + const operationResult: IOperationExecutionResult = rawOperationResults.get(dependency)!; + if (operationResult.silent) { for (const deepDependency of getNonSilentDependencies(dependency)) { realDependencies.add(deepDependency); } @@ -568,8 +584,8 @@ export class PhasedScriptAction extends BaseScriptAction { return realDependencies; } - for (const [operation, operationResult] of result.operationResults) { - if (operation.runner?.silent) { + for (const [operation, operationResult] of rawOperationResults) { + if (operationResult.silent) { // Architectural operation. Ignore. continue; } diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 34301a8af18..788a42aaac5 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -47,6 +47,8 @@ export { RushConfigurationProject } from './api/RushConfigurationProject'; export { RushUserConfiguration } from './api/RushUserConfiguration'; +export { RushCommandLineParser } from './cli/RushCommandLineParser'; + export { RushGlobalFolder as _RushGlobalFolder } from './api/RushGlobalFolder'; export { ApprovedPackagesItem, ApprovedPackagesConfiguration } from './api/ApprovedPackagesConfiguration'; @@ -112,3 +114,11 @@ export { ICredentialCacheOptions, ICredentialCacheEntry, CredentialCache } from export type { ITelemetryData, ITelemetryMachineInfo, ITelemetryOperationResult } from './logic/Telemetry'; export { IStopwatchResult } from './utilities/Stopwatch'; + +export { + ITransferableOperation, + ITransferableOperationStatus, + IDiagnostic, + PhasedCommandWorkerState +} from './worker/RushWorker.types'; +export { PhasedCommandWorkerController, IPhasedCommandWorkerOptions } from './worker/RushWorkerHost'; diff --git a/libraries/rush-lib/src/logic/Git.ts b/libraries/rush-lib/src/logic/Git.ts index 9e728ff8849..65cf37aa366 100644 --- a/libraries/rush-lib/src/logic/Git.ts +++ b/libraries/rush-lib/src/logic/Git.ts @@ -211,7 +211,7 @@ export class Git { let repoInfo: gitInfo.GitRepoInfo | undefined; try { // gitInfo() shouldn't usually throw, but wrapping in a try/catch just in case - repoInfo = gitInfo(); + repoInfo = gitInfo(this._rushConfiguration.rushJsonFolder); } catch (ex) { // if there's an error, assume we're not in a Git working tree } diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index da0f2cd2413..5237aa2ddaa 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -66,7 +66,7 @@ export class ProjectChangeAnalyzer { */ private _data: IRawRepoState | UNINITIALIZED | undefined = UNINITIALIZED; private readonly _filteredData: Map> = new Map(); - private readonly _projectStateCache: Map = new Map(); + private readonly _projectStateCache: Map, string> = new Map(); private readonly _rushConfiguration: RushConfiguration; private readonly _git: Git; @@ -148,31 +148,55 @@ export class ProjectChangeAnalyzer { project: RushConfigurationProject, terminal: ITerminal ): Promise { - let projectState: string | undefined = this._projectStateCache.get(project); + const packageDeps: Map | undefined = await this._tryGetProjectDependenciesAsync( + project, + terminal + ); + return packageDeps ? this._hashProjectDependencies(packageDeps) : undefined; + } + + /** + * @internal + */ + public _hashProjectDependencies(packageDeps: Map): string { + let projectState: string | undefined = this._projectStateCache.get(packageDeps); if (!projectState) { - const packageDeps: Map | undefined = await this._tryGetProjectDependenciesAsync( - project, - terminal - ); + const sortedPackageDepsFiles: string[] = Array.from(packageDeps.keys()).sort(); + const hash: crypto.Hash = crypto.createHash('sha1'); + for (const packageDepsFile of sortedPackageDepsFiles) { + hash.update(packageDepsFile); + hash.update(RushConstants.hashDelimiter); + hash.update(packageDeps.get(packageDepsFile)!); + hash.update(RushConstants.hashDelimiter); + } - if (!packageDeps) { - return undefined; - } else { - const sortedPackageDepsFiles: string[] = Array.from(packageDeps.keys()).sort(); - const hash: crypto.Hash = crypto.createHash('sha1'); - for (const packageDepsFile of sortedPackageDepsFiles) { - hash.update(packageDepsFile); - hash.update(RushConstants.hashDelimiter); - hash.update(packageDeps.get(packageDepsFile)!); - hash.update(RushConstants.hashDelimiter); - } + projectState = hash.digest('hex'); + this._projectStateCache.set(packageDeps, projectState); + } + return projectState; + } - projectState = hash.digest('hex'); - this._projectStateCache.set(project, projectState); - } + /** + * @internal + */ + public _getOperationStateHash(localHash: string, dependencyHashes: string[]): string { + if (!localHash) { + return ''; } - return projectState; + for (const dependencyHash of dependencyHashes) { + if (!dependencyHash) { + return ''; + } + } + const sortedHashes: string[] = dependencyHashes.sort(); + const hash: crypto.Hash = crypto.createHash('sha1'); + hash.update(localHash); + for (const dependencyHash of sortedHashes) { + hash.update(dependencyHash); + hash.update(RushConstants.hashDelimiter); + } + return hash.digest('hex'); } public async _filterProjectDataAsync( diff --git a/libraries/rush-lib/src/logic/RushConstants.ts b/libraries/rush-lib/src/logic/RushConstants.ts index 92e4acdb4d1..d23e5fb9582 100644 --- a/libraries/rush-lib/src/logic/RushConstants.ts +++ b/libraries/rush-lib/src/logic/RushConstants.ts @@ -168,7 +168,7 @@ export class RushConstants { * Build cache version number, incremented when the logic to create cache entries changes. * Changing this ensures that cache entries generated by an old version will no longer register as a cache hit. */ - public static readonly buildCacheVersion: number = 1; + public static readonly buildCacheVersion: number = 2; /** * Per-project configuration filename. diff --git a/libraries/rush-lib/src/logic/buildCache/ProjectBuildCache.ts b/libraries/rush-lib/src/logic/buildCache/ProjectBuildCache.ts index da9876e62b5..e14d65e44ef 100644 --- a/libraries/rush-lib/src/logic/buildCache/ProjectBuildCache.ts +++ b/libraries/rush-lib/src/logic/buildCache/ProjectBuildCache.ts @@ -9,7 +9,6 @@ import * as tar from 'tar'; import { FileSystem, Path, ITerminal, FolderItem } from '@rushstack/node-core-library'; import { RushConfigurationProject } from '../../api/RushConfigurationProject'; -import { ProjectChangeAnalyzer } from '../ProjectChangeAnalyzer'; import { RushProjectConfiguration } from '../../api/RushProjectConfiguration'; import { RushConstants } from '../RushConstants'; import { BuildCacheConfiguration } from '../../api/BuildCacheConfiguration'; @@ -23,8 +22,8 @@ export interface IProjectBuildCacheOptions { projectConfiguration: RushProjectConfiguration; projectOutputFolderNames: ReadonlyArray; command: string; - trackedProjectFiles: string[] | undefined; - projectChangeAnalyzer: ProjectChangeAnalyzer; + trackedProjectFiles: Iterable | undefined; + hash: string; terminal: ITerminal; phaseName: string; } @@ -71,8 +70,8 @@ export class ProjectBuildCache { public static async tryGetProjectBuildCache( options: IProjectBuildCacheOptions ): Promise { - const { terminal, projectConfiguration, projectOutputFolderNames, trackedProjectFiles } = options; - if (!trackedProjectFiles) { + const { terminal, projectConfiguration, projectOutputFolderNames, trackedProjectFiles, hash } = options; + if (!trackedProjectFiles || !hash) { return undefined; } @@ -95,7 +94,7 @@ export class ProjectBuildCache { terminal: ITerminal, projectConfiguration: RushProjectConfiguration, projectOutputFolderNames: ReadonlyArray, - trackedProjectFiles: string[] + trackedProjectFiles: Iterable ): boolean { const normalizedProjectRelativeFolder: string = Path.convertToSlashes( projectConfiguration.project.projectRelativeFolder @@ -172,6 +171,7 @@ export class ProjectBuildCache { } terminal.writeLine('Build cache hit.'); + terminal.writeVerboseLine(cacheId); const projectFolderPath: string = this._project.projectFolder; @@ -440,49 +440,12 @@ export class ProjectBuildCache { private static async _getCacheId(options: IProjectBuildCacheOptions): Promise { // The project state hash is calculated in the following method: - // - The current project's hash (see ProjectChangeAnalyzer.getProjectStateHash) is - // calculated and appended to an array - // - The current project's recursive dependency projects' hashes are calculated - // and appended to the array // - A SHA1 hash is created and the following data is fed into it, in order: // 1. The JSON-serialized list of output folder names for this // project (see ProjectBuildCache._projectOutputFolderNames) // 2. The command that will be run in the project - // 3. Each dependency project hash (from the array constructed in previous steps), - // in sorted alphanumerical-sorted order + // 3. The hash of the projet inputs // - A hex digest of the hash is returned - const projectChangeAnalyzer: ProjectChangeAnalyzer = options.projectChangeAnalyzer; - const projectStates: string[] = []; - const projectsThatHaveBeenProcessed: Set = new Set(); - let projectsToProcess: Set = new Set(); - projectsToProcess.add(options.projectConfiguration.project); - - while (projectsToProcess.size > 0) { - const newProjectsToProcess: Set = new Set(); - for (const projectToProcess of projectsToProcess) { - projectsThatHaveBeenProcessed.add(projectToProcess); - - const projectState: string | undefined = await projectChangeAnalyzer._tryGetProjectStateHashAsync( - projectToProcess, - options.terminal - ); - if (!projectState) { - // If we hit any projects with unknown state, return unknown cache ID - return undefined; - } else { - projectStates.push(projectState); - for (const dependency of projectToProcess.dependencyProjects) { - if (!projectsThatHaveBeenProcessed.has(dependency)) { - newProjectsToProcess.add(dependency); - } - } - } - } - - projectsToProcess = newProjectsToProcess; - } - - const sortedProjectStates: string[] = projectStates.sort(); const hash: crypto.Hash = crypto.createHash('sha1'); // This value is used to force cache bust when the build cache algorithm changes hash.update(`${RushConstants.buildCacheVersion}`); @@ -492,10 +455,8 @@ export class ProjectBuildCache { hash.update(RushConstants.hashDelimiter); hash.update(options.command); hash.update(RushConstants.hashDelimiter); - for (const projectHash of sortedProjectStates) { - hash.update(projectHash); - hash.update(RushConstants.hashDelimiter); - } + hash.update(options.hash); + hash.update(RushConstants.hashDelimiter); const projectStateHash: string = hash.digest('hex'); diff --git a/libraries/rush-lib/src/logic/buildCache/test/ProjectBuildCache.test.ts b/libraries/rush-lib/src/logic/buildCache/test/ProjectBuildCache.test.ts index c690607a45a..d0336b584c8 100644 --- a/libraries/rush-lib/src/logic/buildCache/test/ProjectBuildCache.test.ts +++ b/libraries/rush-lib/src/logic/buildCache/test/ProjectBuildCache.test.ts @@ -4,7 +4,6 @@ import { StringBufferTerminalProvider, Terminal } from '@rushstack/node-core-library'; import { BuildCacheConfiguration } from '../../../api/BuildCacheConfiguration'; import { RushProjectConfiguration } from '../../../api/RushProjectConfiguration'; -import { ProjectChangeAnalyzer } from '../../ProjectChangeAnalyzer'; import { IGenerateCacheEntryIdOptions } from '../CacheEntryId'; import { FileSystemBuildCacheProvider } from '../FileSystemBuildCacheProvider'; @@ -19,11 +18,7 @@ interface ITestOptions { describe(ProjectBuildCache.name, () => { async function prepareSubject(options: Partial): Promise { const terminal: Terminal = new Terminal(new StringBufferTerminalProvider()); - const projectChangeAnalyzer = { - [ProjectChangeAnalyzer.prototype._tryGetProjectStateHashAsync.name]: async () => { - return 'state_hash'; - } - } as unknown as ProjectChangeAnalyzer; + const hash: string = 'state_hash'; const subject: ProjectBuildCache | undefined = await ProjectBuildCache.tryGetProjectBuildCache({ buildCacheConfiguration: { @@ -45,7 +40,7 @@ describe(ProjectBuildCache.name, () => { } as unknown as RushProjectConfiguration, command: 'build', trackedProjectFiles: options.hasOwnProperty('trackedProjectFiles') ? options.trackedProjectFiles : [], - projectChangeAnalyzer, + hash, terminal, phaseName: 'build' }); @@ -56,9 +51,7 @@ describe(ProjectBuildCache.name, () => { describe(ProjectBuildCache.tryGetProjectBuildCache.name, () => { it('returns a ProjectBuildCache with a calculated cacheId value', async () => { const subject: ProjectBuildCache = (await prepareSubject({}))!; - expect(subject['_cacheId']).toMatchInlineSnapshot( - `"acme-wizard/1926f30e8ed24cb47be89aea39e7efd70fcda075"` - ); + expect(subject['_cacheId']).toMatchSnapshot(); }); it('returns undefined if the tracked file list is undefined', async () => { diff --git a/libraries/rush-lib/src/logic/operations/AsyncOperationQueue.ts b/libraries/rush-lib/src/logic/operations/AsyncOperationQueue.ts index 006df21d840..e2186f2cbc8 100644 --- a/libraries/rush-lib/src/logic/operations/AsyncOperationQueue.ts +++ b/libraries/rush-lib/src/logic/operations/AsyncOperationQueue.ts @@ -4,6 +4,10 @@ import { OperationExecutionRecord } from './OperationExecutionRecord'; import { OperationStatus } from './OperationStatus'; +export interface IAbortSignal { + aborted: boolean; +} + /** * Implmentation of the async iteration protocol for a collection of IOperation objects. * The async iterator will wait for an operation to be ready for execution, or terminate if there are no more operations. @@ -18,6 +22,7 @@ export class AsyncOperationQueue { private readonly _queue: OperationExecutionRecord[]; private readonly _pendingIterators: ((result: IteratorResult) => void)[]; + private readonly _abortSignal: IAbortSignal | undefined; /** * @param operations - The set of operations to be executed @@ -26,9 +31,14 @@ export class AsyncOperationQueue * - Returning a negative value indicates that `b` should execute before `a`. * - Returning 0 indicates no preference. */ - public constructor(operations: Iterable, sortFn: IOperationSortFunction) { + public constructor( + operations: Iterable, + sortFn: IOperationSortFunction, + abortSignal?: IAbortSignal + ) { this._queue = computeTopologyAndSort(operations, sortFn); this._pendingIterators = []; + this._abortSignal = abortSignal; } /** @@ -56,6 +66,11 @@ export class AsyncOperationQueue public assignOperations(): void { const { _queue: queue, _pendingIterators: waitingIterators } = this; + if (this._abortSignal?.aborted) { + // Aborted. Delete queue. + queue.length = 0; + } + // By iterating in reverse order we do less array shuffling when removing operations for (let i: number = queue.length - 1; waitingIterators.length > 0 && i >= 0; i--) { const operation: OperationExecutionRecord = queue[i]; diff --git a/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts b/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts index 83e3f1a0612..706e22da9c7 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts @@ -18,19 +18,27 @@ export interface IOperationExecutionResult { * 'failure'. */ readonly status: OperationStatus; + /** * The error which occurred while executing this operation, this is stored in case we need * it later (for example to re-print errors at end of execution). */ readonly error: Error | undefined; + /** * Object tracking execution timing. */ readonly stopwatch: IStopwatchResult; + /** * Object used to report a summary at the end of the Rush invocation. */ readonly stdioSummarizer: StdioSummarizer; + + /** + * Indicates that this operation should be ignored for results collation. + */ + readonly silent: boolean; } /** diff --git a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts index 83db014c090..42d0523c45f 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts @@ -24,10 +24,25 @@ export interface IOperationRunnerContext { * Defaults to `true`. Will be `false` if Rush was invoked with `--verbose`. */ quietMode: boolean; + /** + * Defaults to `true`. Will be `false` if a dependency is in an unknown state. + */ + isCacheWriteAllowed: boolean; /** * Object used to report a summary at the end of the Rush invocation. */ stdioSummarizer: StdioSummarizer; + + // Temporary pending moving this to higher level + /** + * The hashes of all tracked files pertinent to the operation + */ + trackedFileHashes: ReadonlyMap | undefined; + + /** + * The hash of all inputs to the operation + */ + stateHash: string | undefined; } /** diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts index a3235a395c1..ff38dc41c0a 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts @@ -4,14 +4,16 @@ import * as os from 'os'; import colors from 'colors/safe'; import { TerminalWritable, StdioWritable, TextRewriterTransform } from '@rushstack/terminal'; -import { StreamCollator, CollatedTerminal, CollatedWriter } from '@rushstack/stream-collator'; -import { NewlineKind, Async } from '@rushstack/node-core-library'; +import { StreamCollator, CollatedWriter } from '@rushstack/stream-collator'; +import { NewlineKind, Async, Terminal, ITerminal } from '@rushstack/node-core-library'; -import { AsyncOperationQueue, IOperationSortFunction } from './AsyncOperationQueue'; +import { AsyncOperationQueue, IOperationSortFunction, IAbortSignal } from './AsyncOperationQueue'; import { Operation } from './Operation'; import { OperationStatus } from './OperationStatus'; import { IOperationExecutionRecordContext, OperationExecutionRecord } from './OperationExecutionRecord'; -import { IExecutionResult } from './IOperationExecutionResult'; +import { ProjectChangeAnalyzer } from '../ProjectChangeAnalyzer'; +import { RushConfigurationProject } from '../../api/RushConfigurationProject'; +import { CollatedTerminalProvider } from '../../utilities/CollatedTerminalProvider'; export interface IOperationExecutionManagerOptions { quietMode: boolean; @@ -19,6 +21,15 @@ export interface IOperationExecutionManagerOptions { parallelism: string | undefined; changedProjectsOnly: boolean; destination?: TerminalWritable; + + onOperationStatusChanged?: (record: OperationExecutionRecord) => void; + beforeExecuteOperations?: (records: Map) => void; + afterOperationHashes?: (records: Map) => void; +} + +export interface IFullExecutionResult { + status: OperationStatus; + operationResults: Map; } /** @@ -37,26 +48,42 @@ export class OperationExecutionManager { private readonly _executionRecords: Map; private readonly _quietMode: boolean; private readonly _parallelism: number; - private readonly _totalOperations: number; private readonly _outputWritable: TerminalWritable; private readonly _colorsNewlinesTransform: TextRewriterTransform; private readonly _streamCollator: StreamCollator; - private readonly _terminal: CollatedTerminal; + private readonly _terminal: ITerminal; + + private readonly _onOperationStatusChanged?: (record: OperationExecutionRecord) => void; + private readonly _afterOperationHashes?: (records: Map) => void; + private readonly _beforeExecuteOperations?: (records: Map) => void; // Variables for current status private _hasAnyFailures: boolean; private _hasAnyNonAllowedWarnings: boolean; private _completedOperations: number; + private _totalOperations: number; public constructor(operations: Set, options: IOperationExecutionManagerOptions) { - const { quietMode, debugMode, parallelism, changedProjectsOnly } = options; + const { + quietMode, + debugMode, + parallelism, + changedProjectsOnly, + onOperationStatusChanged, + beforeExecuteOperations, + afterOperationHashes + } = options; this._completedOperations = 0; + this._totalOperations = 0; this._quietMode = quietMode; this._hasAnyFailures = false; this._hasAnyNonAllowedWarnings = false; this._changedProjectsOnly = changedProjectsOnly; + this._onOperationStatusChanged = onOperationStatusChanged; + this._afterOperationHashes = afterOperationHashes; + this._beforeExecuteOperations = beforeExecuteOperations; // TERMINAL PIPELINE: // @@ -72,16 +99,16 @@ export class OperationExecutionManager { destination: this._colorsNewlinesTransform, onWriterActive: this._streamCollator_onWriterActive }); - this._terminal = this._streamCollator.terminal; + this._terminal = new Terminal(new CollatedTerminalProvider(this._streamCollator.terminal)); // Convert the developer graph to the mutable execution graph const executionRecordContext: IOperationExecutionRecordContext = { streamCollator: this._streamCollator, + onOperationStatusChanged, debugMode, quietMode }; - let totalOperations: number = 0; const executionRecords: Map = (this._executionRecords = new Map()); for (const operation of operations) { const executionRecord: OperationExecutionRecord = new OperationExecutionRecord( @@ -90,12 +117,7 @@ export class OperationExecutionManager { ); executionRecords.set(operation, executionRecord); - if (!executionRecord.runner.silent) { - // Only count non-silent operations - totalOperations++; - } } - this._totalOperations = totalOperations; for (const [operation, consumer] of executionRecords) { for (const dependency of operation.dependencies) { @@ -179,10 +201,10 @@ export class OperationExecutionManager { const middlePart: string = colors.gray(']' + '='.repeat(middlePartLengthMinusTwoBrackets) + '['); - this._terminal.writeStdoutLine('\n' + leftPart + middlePart + rightPart); + this._terminal.writeLine('\n' + leftPart + middlePart + rightPart); if (!this._quietMode) { - this._terminal.writeStdoutLine(''); + this._terminal.writeLine(''); } } }; @@ -191,27 +213,76 @@ export class OperationExecutionManager { * Executes all operations which have been registered, returning a promise which is resolved when all the * operations are completed successfully, or rejects when any operation fails. */ - public async executeAsync(): Promise { + public async executeAsync( + projectChangeAnalyzer?: ProjectChangeAnalyzer, + abortSignal?: IAbortSignal + ): Promise { this._completedOperations = 0; - const totalOperations: number = this._totalOperations; - if (!this._quietMode) { - const plural: string = totalOperations === 1 ? '' : 's'; - this._terminal.writeStdoutLine(`Selected ${totalOperations} operation${plural}:`); - const nonSilentOperations: string[] = []; - for (const record of this._executionRecords.values()) { - if (!record.runner.silent) { - nonSilentOperations.push(record.name); + if (projectChangeAnalyzer) { + const state: ProjectChangeAnalyzer = projectChangeAnalyzer; + this._terminal.writeLine(`Updating state hashes`); + const trackedFilesByProject: Map | undefined> = new Map(); + for (const { associatedProject } of this._executionRecords.keys()) { + if (associatedProject) { + trackedFilesByProject.set(associatedProject, undefined); } } + + await Async.forEachAsync(trackedFilesByProject.keys(), async (project) => { + const trackedFiles: Map | undefined = await state._tryGetProjectDependenciesAsync( + project, + this._terminal + ); + trackedFilesByProject.set(project, trackedFiles); + }); + + function getOperationHash(record: OperationExecutionRecord): string { + let { stateHash } = record; + if (stateHash === undefined) { + const { associatedProject } = record.operation; + stateHash = ''; + if (associatedProject) { + const trackedFiles: Map | undefined = + trackedFilesByProject.get(associatedProject); + record.trackedFileHashes = trackedFiles; + const localHash: string = trackedFiles ? state._hashProjectDependencies(trackedFiles) : ''; + if (localHash) { + stateHash = state._getOperationStateHash( + localHash, + Array.from(record.dependencies, getOperationHash) + ); + } + } + record.stateHash = stateHash; + } + return stateHash; + } + + for (const record of this._executionRecords.values()) { + getOperationHash(record); + } + } + + this._afterOperationHashes?.(this._executionRecords); + const nonSilentOperations: string[] = []; + for (const record of this._executionRecords.values()) { + if (!record.silent) { + nonSilentOperations.push(record.name); + } + } + const totalOperations: number = (this._totalOperations = nonSilentOperations.length); + if (!this._quietMode) { + const plural: string = totalOperations === 1 ? '' : 's'; + this._terminal.writeLine(`Selected ${totalOperations} operation${plural}:`); nonSilentOperations.sort(); for (const name of nonSilentOperations) { - this._terminal.writeStdoutLine(` ${name}`); + this._terminal.writeLine(` ${name}`); } - this._terminal.writeStdoutLine(''); + this._terminal.writeLine(''); } - this._terminal.writeStdoutLine(`Executing a maximum of ${this._parallelism} simultaneous processes...`); + this._terminal.writeLine(`Executing a maximum of ${this._parallelism} simultaneous processes...`); const maxParallelism: number = Math.min(totalOperations, this._parallelism); const prioritySort: IOperationSortFunction = ( @@ -222,9 +293,12 @@ export class OperationExecutionManager { }; const executionQueue: AsyncOperationQueue = new AsyncOperationQueue( this._executionRecords.values(), - prioritySort + prioritySort, + abortSignal ); + this._beforeExecuteOperations?.(this._executionRecords); + // This function is a callback because it may write to the collatedWriter before // operation.executeAsync returns (and cleans up the writer) const onOperationComplete: (record: OperationExecutionRecord) => void = ( @@ -236,7 +310,9 @@ export class OperationExecutionManager { await Async.forEachAsync( executionQueue, async (operation: OperationExecutionRecord) => { - await operation.executeAsync(onOperationComplete); + if (!abortSignal?.aborted) { + await operation.executeAsync(onOperationComplete); + } }, { concurrency: maxParallelism @@ -288,10 +364,11 @@ export class OperationExecutionManager { // Now that we have the concept of architectural no-ops, we could implement this by replacing // {blockedRecord.runner} with a no-op that sets status to Blocked and logs the blocking // operations. However, the existing behavior is a bit simpler, so keeping that for now. - if (!blockedRecord.runner.silent) { + if (!blockedRecord.silent) { terminal.writeStdoutLine(`"${blockedRecord.name}" is blocked by "${name}".`); } blockedRecord.status = OperationStatus.Blocked; + this._onOperationStatusChanged?.(blockedRecord); for (const dependent of blockedRecord.consumers) { blockedQueue.add(dependent); @@ -363,10 +440,11 @@ export class OperationExecutionManager { // Apply status changes to direct dependents for (const item of record.consumers) { if (blockCacheWrite) { - item.runner.isCacheWriteAllowed = false; + item.isCacheWriteAllowed = false; } if (blockSkip) { + // Only relevant in legacy non-build cache flow item.runner.isSkipAllowed = false; } diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index cbdb7886cb5..649b53476b7 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { StdioSummarizer } from '@rushstack/terminal'; -import { InternalError } from '@rushstack/node-core-library'; +import { StdioSummarizer, IDiagnostic } from '@rushstack/terminal'; +import { InternalError, AnsiEscape } from '@rushstack/node-core-library'; import { CollatedWriter, StreamCollator } from '@rushstack/stream-collator'; import { OperationStatus } from './OperationStatus'; @@ -11,12 +11,30 @@ import { Operation } from './Operation'; import { Stopwatch } from '../../utilities/Stopwatch'; export interface IOperationExecutionRecordContext { + onOperationStatusChanged?: (record: OperationExecutionRecord) => void; streamCollator: StreamCollator; debugMode: boolean; quietMode: boolean; } +const heftDiagnosticRegexp: RegExp = + /^\s*(\[[^\]]+\])?\s*(warning|error):\s+([^:]+):(\d+):(\d+)\s+-\s+(.+)\n/i; +function heftDiagnosticMatcher(line: string): IDiagnostic | undefined { + const stripped: string = AnsiEscape.removeCodes(line); + const match: RegExpMatchArray | null = stripped.match(heftDiagnosticRegexp); + if (match) { + return { + file: match[3], + line: parseInt(match[4]), + column: parseInt(match[5]), + severity: match[2].toLowerCase() as 'warning' | 'error', + message: match[6], + tag: match[1] + }; + } +} + /** * Internal class representing everything about executing an operation */ @@ -66,6 +84,14 @@ export class OperationExecutionRecord implements IOperationRunnerContext { */ public criticalPathLength: number | undefined = undefined; + public silent: boolean = false; + + public isCacheWriteAllowed: boolean = false; + + public trackedFileHashes: Map | undefined = undefined; + + public stateHash: string | undefined = undefined; + /** * The set of operations that must complete before this operation executes. */ @@ -75,10 +101,12 @@ export class OperationExecutionRecord implements IOperationRunnerContext { */ public readonly consumers: Set = new Set(); + public readonly operation: Operation; + public readonly stopwatch: Stopwatch = new Stopwatch(); - public readonly stdioSummarizer: StdioSummarizer = new StdioSummarizer(); + public readonly stdioSummarizer: StdioSummarizer; - public readonly runner: IOperationRunner; + public runner: IOperationRunner; public readonly weight: number; private readonly _context: IOperationExecutionRecordContext; @@ -86,6 +114,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext { private _collatedWriter: CollatedWriter | undefined = undefined; public constructor(operation: Operation, context: IOperationExecutionRecordContext) { + this.operation = operation; const { runner } = operation; if (!runner) { @@ -93,6 +122,12 @@ export class OperationExecutionRecord implements IOperationRunnerContext { `Operation for phase '${operation.associatedPhase?.name}' and project '${operation.associatedProject?.packageName}' has no runner.` ); } + this.silent = runner.silent; + this.isCacheWriteAllowed = runner.isCacheWriteAllowed; + + this.stdioSummarizer = new StdioSummarizer({ + diagnosticMatcher: heftDiagnosticMatcher + }); this.runner = runner; this.weight = operation.weight; @@ -122,6 +157,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext { public async executeAsync(onResult: (record: OperationExecutionRecord) => void): Promise { this.status = OperationStatus.Executing; this.stopwatch.start(); + this._context.onOperationStatusChanged?.(this); try { this.status = await this.runner.executeAsync(this); @@ -133,9 +169,11 @@ export class OperationExecutionRecord implements IOperationRunnerContext { // Delegate global state reporting onResult(this); } finally { + this.silent = this.runner.silent; this._collatedWriter?.close(); this.stdioSummarizer.close(); this.stopwatch.stop(); + this._context.onOperationStatusChanged?.(this); } } } diff --git a/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts b/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts index e328fe78ef1..13bc3356aa2 100644 --- a/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts @@ -51,7 +51,7 @@ export function _printOperationStatus(terminal: ITerminal, result: IExecutionRes const operationsByStatus: IOperationsByStatus = new Map(); for (const record of operationResults) { - if (record[0].runner?.silent) { + if (record[1].silent) { // Don't report silenced operations continue; } diff --git a/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts index f07984f2a6e..d00f1d1bda9 100644 --- a/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts @@ -111,6 +111,6 @@ function createOperations( } // Convert the [IPhase, RushConfigurationProject] into a value suitable for use as a Map key -function getOperationKey(phase: IPhase, project: RushConfigurationProject): string { +export function getOperationKey(phase: IPhase, project: RushConfigurationProject): string { return `${project.packageName};${phase.name}`; } diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts index 4384b637bbd..9dc9c9f65d3 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts @@ -26,7 +26,7 @@ import { CollatedTerminal } from '@rushstack/stream-collator'; import type { RushConfiguration } from '../../api/RushConfiguration'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; -import { Utilities, UNINITIALIZED } from '../../utilities/Utilities'; +import { Utilities } from '../../utilities/Utilities'; import { OperationStatus } from './OperationStatus'; import { OperationError } from './OperationError'; import type { ProjectChangeAnalyzer } from '../ProjectChangeAnalyzer'; @@ -96,17 +96,10 @@ export class ShellOperationRunner implements IOperationRunner { private readonly _commandName: string; private readonly _commandToRun: string; private readonly _isCacheReadAllowed: boolean; - private readonly _projectChangeAnalyzer: ProjectChangeAnalyzer; private readonly _packageDepsFilename: string; private readonly _logFilenameIdentifier: string; private readonly _selectedPhases: Iterable; - /** - * UNINITIALIZED === we haven't tried to initialize yet - * undefined === we didn't create one because the feature is not enabled - */ - private _projectBuildCache: ProjectBuildCache | undefined | UNINITIALIZED = UNINITIALIZED; - public constructor(options: IOperationRunnerOptions) { const { phase } = options; @@ -119,7 +112,6 @@ export class ShellOperationRunner implements IOperationRunner { this._commandToRun = options.commandToRun; this._isCacheReadAllowed = options.isIncrementalBuildAllowed; this.isSkipAllowed = options.isIncrementalBuildAllowed; - this._projectChangeAnalyzer = options.projectChangeAnalyzer; this._packageDepsFilename = `package-deps_${phase.logFilenameIdentifier}.json`; this.warningsAreAllowed = EnvironmentConfiguration.allowWarningsInSuccessfulBuild || phase.allowWarningsOnSuccess || false; @@ -150,6 +142,8 @@ export class ShellOperationRunner implements IOperationRunner { ); try { + const { trackedFileHashes, stateHash } = context; + const removeColorsTransform: TextRewriterTransform = new TextRewriterTransform({ destination: projectLogWritable, removeColors: true, @@ -207,40 +201,25 @@ export class ShellOperationRunner implements IOperationRunner { } let projectDeps: IProjectDeps | undefined; - let trackedFiles: string[] | undefined; - try { - const fileHashes: Map | undefined = - await this._projectChangeAnalyzer._tryGetProjectDependenciesAsync(this._rushProject, terminal); - - if (fileHashes) { - const files: { [filePath: string]: string } = {}; - trackedFiles = []; - for (const [filePath, fileHash] of fileHashes) { - files[filePath] = fileHash; - trackedFiles.push(filePath); - } - - projectDeps = { - files, - arguments: this._commandToRun - }; - } else if (this.isSkipAllowed) { - // To test this code path: - // Remove the `.git` folder then run "rush build --verbose" - terminal.writeLine({ - text: PrintUtilities.wrapWords( - 'This workspace does not appear to be tracked by Git. ' + - 'Rush will proceed without incremental execution, caching, and change detection.' - ), - foregroundColor: ColorValue.Cyan - }); + const trackedFiles: Iterable | undefined = trackedFileHashes?.keys(); + if (trackedFileHashes) { + const files: { [filePath: string]: string } = {}; + for (const [filePath, fileHash] of trackedFileHashes) { + files[filePath] = fileHash; } - } catch (error) { + + projectDeps = { + files, + arguments: this._commandToRun + }; + } else if (this.isSkipAllowed) { // To test this code path: - // Delete a project's ".rush/temp/shrinkwrap-deps.json" then run "rush build --verbose" - terminal.writeLine('Unable to calculate incremental state: ' + (error as Error).toString()); + // Remove the `.git` folder then run "rush build --verbose" terminal.writeLine({ - text: 'Rush will proceed without incremental execution, caching, and change detection.', + text: PrintUtilities.wrapWords( + 'This workspace does not appear to be tracked by Git. ' + + 'Rush will proceed without incremental execution, caching, and change detection.' + ), foregroundColor: ColorValue.Cyan }); } @@ -262,15 +241,15 @@ export class ShellOperationRunner implements IOperationRunner { // false if a dependency wasn't able to be skipped. // let buildCacheReadAttempted: boolean = false; + let projectBuildCache: ProjectBuildCache | false | undefined; if (this._isCacheReadAllowed) { - const projectBuildCache: ProjectBuildCache | undefined = await this._tryGetProjectBuildCacheAsync( - terminal, - trackedFiles - ); + projectBuildCache = + (await this._tryGetProjectBuildCacheAsync(terminal, trackedFiles, stateHash)) || false; buildCacheReadAttempted = !!projectBuildCache; - const restoreFromCacheSuccess: boolean | undefined = - await projectBuildCache?.tryRestoreFromCacheAsync(terminal); + const restoreFromCacheSuccess: boolean | undefined = projectBuildCache + ? await projectBuildCache.tryRestoreFromCacheAsync(terminal) + : undefined; if (restoreFromCacheSuccess) { return OperationStatus.FromCache; @@ -372,11 +351,15 @@ export class ShellOperationRunner implements IOperationRunner { // If the command is successful, we can calculate project hash, and no dependencies were skipped, // write a new cache entry. - const setCacheEntryPromise: Promise | undefined = this.isCacheWriteAllowed - ? (await this._tryGetProjectBuildCacheAsync(terminal, trackedFiles))?.trySetCacheEntryAsync( - terminal - ) - : undefined; + let setCacheEntryPromise: Promise | undefined; + if (context.isCacheWriteAllowed) { + if (projectBuildCache === undefined) { + projectBuildCache = await this._tryGetProjectBuildCacheAsync(terminal, trackedFiles, stateHash); + } + if (projectBuildCache) { + setCacheEntryPromise = projectBuildCache.trySetCacheEntryAsync(terminal); + } + } const [, cacheWriteSuccess] = await Promise.all([writeProjectStatePromise, setCacheEntryPromise]); @@ -403,57 +386,62 @@ export class ShellOperationRunner implements IOperationRunner { private async _tryGetProjectBuildCacheAsync( terminal: ITerminal, - trackedProjectFiles: string[] | undefined + trackedProjectFiles: Iterable | undefined, + stateHash: string | undefined ): Promise { - if (this._projectBuildCache === UNINITIALIZED) { - this._projectBuildCache = undefined; - - if (this._buildCacheConfiguration && this._buildCacheConfiguration.buildCacheEnabled) { - // Disable legacy skip logic if the build cache is in play - this.isSkipAllowed = false; - - const projectConfiguration: RushProjectConfiguration | undefined = - await RushProjectConfiguration.tryLoadForProjectAsync(this._rushProject, terminal); - if (projectConfiguration) { - projectConfiguration.validatePhaseConfiguration(this._selectedPhases, terminal); - if (projectConfiguration.disableBuildCacheForProject) { - terminal.writeVerboseLine('Caching has been disabled for this project.'); + if (!stateHash) { + // To test this code path: + // Delete a project's ".rush/temp/shrinkwrap-deps.json" then run "rush build --verbose" + terminal.writeLine('Unable to calculate incremental state.'); + terminal.writeLine({ + text: 'Rush will proceed without incremental execution, caching, and change detection.', + foregroundColor: ColorValue.Cyan + }); + return; + } + + if (this._buildCacheConfiguration && this._buildCacheConfiguration.buildCacheEnabled) { + // Disable legacy skip logic if the build cache is in play + this.isSkipAllowed = false; + + const projectConfiguration: RushProjectConfiguration | undefined = + await RushProjectConfiguration.tryLoadForProjectAsync(this._rushProject, terminal); + if (projectConfiguration) { + projectConfiguration.validatePhaseConfiguration(this._selectedPhases, terminal); + if (projectConfiguration.disableBuildCacheForProject) { + terminal.writeVerboseLine('Caching has been disabled for this project.'); + } else { + const operationSettings: IOperationSettings | undefined = + projectConfiguration.operationSettingsByOperationName.get(this._commandName); + if (!operationSettings) { + terminal.writeVerboseLine( + `This project does not define the caching behavior of the "${this._commandName}" command, so caching has been disabled.` + ); + } else if (operationSettings.disableBuildCacheForOperation) { + terminal.writeVerboseLine( + `Caching has been disabled for this project's "${this._commandName}" command.` + ); } else { - const operationSettings: IOperationSettings | undefined = - projectConfiguration.operationSettingsByOperationName.get(this._commandName); - if (!operationSettings) { - terminal.writeVerboseLine( - `This project does not define the caching behavior of the "${this._commandName}" command, so caching has been disabled.` - ); - } else if (operationSettings.disableBuildCacheForOperation) { - terminal.writeVerboseLine( - `Caching has been disabled for this project's "${this._commandName}" command.` - ); - } else { - const projectOutputFolderNames: ReadonlyArray = - operationSettings.outputFolderNames || []; - this._projectBuildCache = await ProjectBuildCache.tryGetProjectBuildCache({ - projectConfiguration, - projectOutputFolderNames, - buildCacheConfiguration: this._buildCacheConfiguration, - terminal, - command: this._commandToRun, - trackedProjectFiles: trackedProjectFiles, - projectChangeAnalyzer: this._projectChangeAnalyzer, - phaseName: this._phase.name - }); - } + const projectOutputFolderNames: ReadonlyArray = operationSettings.outputFolderNames || []; + return await ProjectBuildCache.tryGetProjectBuildCache({ + projectConfiguration, + projectOutputFolderNames, + buildCacheConfiguration: this._buildCacheConfiguration, + terminal, + command: this._commandToRun, + trackedProjectFiles, + hash: stateHash, + phaseName: this._phase.name + }); } - } else { - terminal.writeVerboseLine( - `Project does not have a ${RushConstants.rushProjectConfigFilename} configuration file, ` + - 'or one provided by a rig, so it does not support caching.' - ); } + } else { + terminal.writeVerboseLine( + `Project does not have a ${RushConstants.rushProjectConfigFilename} configuration file, ` + + 'or one provided by a rig, so it does not support caching.' + ); } } - - return this._projectBuildCache; } } diff --git a/libraries/rush-lib/src/worker/RushWorker.types.ts b/libraries/rush-lib/src/worker/RushWorker.types.ts new file mode 100644 index 00000000000..57e3246f5c8 --- /dev/null +++ b/libraries/rush-lib/src/worker/RushWorker.types.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { IDiagnostic } from '@rushstack/terminal'; +import { OperationStatus } from '../logic/operations/OperationStatus'; + +/** + * @alpha + */ +export interface ITransferableOperation { + name?: string; + project?: string; + phase?: string; + + logFilePath?: string; +} + +export type { IDiagnostic }; + +/** + * @alpha + */ +export interface ITransferableOperationStatus { + operation: ITransferableOperation; + + status: OperationStatus; + hash: string | undefined; + duration: number; + active: boolean; + diagnostics?: IDiagnostic[]; +} + +export interface IRushWorkerOperationsMessage { + type: 'operations'; + value: { + operations: ITransferableOperationStatus[]; + }; +} +export interface IRushWorkerGraphMessage { + type: 'graph'; + value: { operations: ITransferableOperation[] }; +} +export interface IRushWorkerReadyMessage { + type: 'ready'; + value: {}; +} +export type IRushWorkerResponse = + | IRushWorkerOperationsMessage + | IRushWorkerGraphMessage + | IRushWorkerReadyMessage; + +export interface IRushWorkerBuildMessage { + type: 'build'; + value: { targets: string[] }; +} +export interface IRushWorkerShutdownMessage { + type: 'shutdown'; + value: {}; +} +export type IRushWorkerRequest = IRushWorkerBuildMessage | IRushWorkerShutdownMessage; + +/** + * @alpha + */ +export type PhasedCommandWorkerState = + | 'initializing' + | 'waiting' + | 'updating' + | 'executing' + | 'exiting' + | 'exited'; diff --git a/libraries/rush-lib/src/worker/RushWorkerCli.ts b/libraries/rush-lib/src/worker/RushWorkerCli.ts new file mode 100644 index 00000000000..127f35bbcb3 --- /dev/null +++ b/libraries/rush-lib/src/worker/RushWorkerCli.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createInterface, Interface } from 'readline'; +import { + ITransferableOperation, + ITransferableOperationStatus, + PhasedCommandWorkerState +} from './RushWorker.types'; +import { PhasedCommandWorkerController } from './RushWorkerHost'; + +/** + * Demo for orchestrating the worker from a CLI process. + */ +async function runAsCli(): Promise { + const workerInterface: PhasedCommandWorkerController = new PhasedCommandWorkerController( + process.argv.slice(3), + { + cwd: process.argv[2], + onStatusUpdates: (statuses: ITransferableOperationStatus[]) => { + for (const status of statuses) { + console.log( + `[HOST]: Status change: ${status.operation.name!} (${status.active ? 'active' : 'inactive'}) => ${ + status.status + } (${status.hash})` + ); + } + }, + onStateChanged: (state: PhasedCommandWorkerState) => { + console.log(`Worker state: ${state}`); + } + } + ); + + const operations: ITransferableOperation[] = await workerInterface.getGraphAsync(); + const operationNames: string[] = operations + .map(({ project, phase }) => { + return `${project};${phase}`; + }) + .sort(); + + console.log(`Available Operations:`); + for (const operation of operationNames) { + console.log(` - ${operation}`); + } + + const rl: Interface = createInterface({ + input: process.stdin, + output: process.stdout, + prompt: `> Select Build Targets> ` + }); + + rl.on('SIGINT', () => { + if (workerInterface.state === 'updating') { + rl.close(); + return; + } + + // Send abort + console.log(`Aborting`); + workerInterface.abort(); + }); + + rl.prompt(); + for await (const line of rl) { + if (line === 'exit') { + rl.close(); + await workerInterface.shutdownAsync(); + } else if (line === 'abort') { + workerInterface.abort(); + } else { + const targets: string[] = line.split(/[, ]/g); + const operations: ITransferableOperation[] = []; + for (const target of targets) { + const [project, phase] = target.split(';'); + operations.push({ project, phase }); + } + + workerInterface.update(operations); + + rl.prompt(); + } + } +} + +if (require.main === module) { + runAsCli().catch(console.error); +} diff --git a/libraries/rush-lib/src/worker/RushWorkerEntry.ts b/libraries/rush-lib/src/worker/RushWorkerEntry.ts new file mode 100644 index 00000000000..86dc28fa452 --- /dev/null +++ b/libraries/rush-lib/src/worker/RushWorkerEntry.ts @@ -0,0 +1,438 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import { workerData, parentPort } from 'worker_threads'; + +import { AlreadyReportedError, Import, JsonFile, Path } from '@rushstack/node-core-library'; + +import { RushCommandLineParser } from '../cli/RushCommandLineParser'; +import { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; +import { IPhasedCommand } from '../pluginFramework/RushLifeCycle'; +import { Operation } from '../logic/operations/Operation'; +import { OperationExecutionRecord } from '../logic/operations/OperationExecutionRecord'; +import { + IExecuteOperationsOptions as IExecuteOperationsOptions, + PhasedScriptAction +} from '../cli/scriptActions/PhasedScriptAction'; +import { IOperationExecutionManagerOptions } from '../logic/operations/OperationExecutionManager'; +import { getOperationKey } from '../logic/operations/PhasedOperationPlugin'; +import { ProjectChangeAnalyzer } from '../logic/ProjectChangeAnalyzer'; +import { ICreateOperationsContext } from '../pluginFramework/PhasedCommandHooks'; +import { NullOperationRunner } from '../logic/operations/NullOperationRunner'; +import { OperationStatus } from '../logic/operations/OperationStatus'; +import { PackageNameParsers } from '../api/PackageNameParsers'; +import { RushConstants } from '../logic/RushConstants'; +import { + ITransferableOperation, + IRushWorkerGraphMessage, + IRushWorkerRequest, + IRushWorkerReadyMessage, + IRushWorkerOperationsMessage, + ITransferableOperationStatus, + IDiagnostic +} from './RushWorker.types'; +import { IAbortSignal } from '../logic/operations/AsyncOperationQueue'; + +const builtInPluginConfigurations: IBuiltInPluginConfiguration[] = []; + +const posixDirname: string = Path.convertToSlashes(__dirname); +const pluginOrigin: string = posixDirname.endsWith('@microsoft/rush-lib/lib/worker') + ? posixDirname + : path.resolve(__dirname, '../../../../apps/rush'); + +function includePlugin(pluginName: string, pluginPackageName?: string): void { + if (!pluginPackageName) { + pluginPackageName = `@rushstack/${pluginName}`; + } + const pluginPackageFolder: string = Import.resolvePackage({ + packageName: pluginPackageName, + baseFolderPath: pluginOrigin + }); + builtInPluginConfigurations.push({ + packageName: pluginPackageName, + pluginName: pluginName, + pluginPackageFolder + }); +} + +includePlugin('rush-amazon-s3-build-cache-plugin'); +includePlugin('rush-azure-storage-build-cache-plugin'); +// Including this here so that developers can reuse it without installing the plugin a second time +includePlugin('rush-azure-interactive-auth-plugin', '@rushstack/rush-azure-storage-build-cache-plugin'); + +if (!workerData || !parentPort) { + console.error(`This worker must be run in a worker context!`); + process.exit(1); +} + +const parser: RushCommandLineParser = new RushCommandLineParser({ + alreadyReportedNodeTooNewError: true, + builtInPluginConfigurations, + excludeDefaultActions: true, + cwd: workerData.cwd +}); + +interface IStateRecord { + status: OperationStatus; + stateHash: string | undefined; + diagnostics: IDiagnostic[] | undefined; + duration: number; +} + +parser.rushSession.hooks.runAnyPhasedCommand.tapPromise( + 'RushWorkerPlugin', + async (command: IPhasedCommand) => { + const stateFilePath: string = `${parser.rushConfiguration.commonTempFolder}/operation-states.json`; + const operationStates: Map = new Map(); + const oldOperationStates: Map = new Map(); + try { + const statesFromFile: [string, IStateRecord][] = await JsonFile.loadAsync(stateFilePath); + for (const [key, record] of statesFromFile) { + oldOperationStates.set(key, record); + } + } catch (err) { + console.log(`Failed to load state file: ${err}`); + } + const includedOperations: Set = new Set(); + + const rawCommand: PhasedScriptAction = command as unknown as PhasedScriptAction; + + const abortSignal: IAbortSignal = { aborted: false }; + + let originalOptions: IExecuteOperationsOptions | undefined; + const originalExecuteOperations: typeof PhasedScriptAction.prototype._executeOperations = + rawCommand._executeOperations; + + const operationByKey: Map = new Map(); + const transferOperationForOperation: Map = new Map(); + const unassociatedOperations: Set = new Set(); + + const { taps: afterExecuteOperationsTaps } = command.hooks.afterExecuteOperations; + for (let i: number = afterExecuteOperationsTaps.length - 1; i >= 0; i--) { + // Hack out the summary for now, since it doesn't handle aborting and is wasted work. + if (afterExecuteOperationsTaps[i].name === 'OperationResultSummarizerPlugin') { + afterExecuteOperationsTaps.splice(i, 1); + } + } + + async function interceptExecuteOperations( + this: PhasedScriptAction, + options: IExecuteOperationsOptions + ): Promise { + originalOptions = options; + options.stopwatch.stop(); + options.ignoreHooks = true; + + const { operations } = options; + + for (const operation of operations) { + const { associatedPhase, associatedProject } = operation; + let logFilePath: string | undefined; + if (associatedPhase && associatedProject) { + const operationKey: string = getOperationKey(associatedPhase, associatedProject); + + operationByKey.set(operationKey, operation); + + const oldState: IStateRecord | undefined = oldOperationStates.get(operationKey); + if (oldState) { + operationStates.set(operation, oldState); + } + + const unscopedProjectName: string = PackageNameParsers.permissive.getUnscopedName( + associatedProject.packageName + ); + + logFilePath = `${Path.convertToSlashes(associatedProject.projectFolder)}/${ + RushConstants.rushLogsFolderName + }/${unscopedProjectName}.${associatedPhase.logFilenameIdentifier}.log`; + } else { + unassociatedOperations.add(operation); + } + + const transferableOperation: ITransferableOperation = { + name: operation.name, + phase: associatedPhase?.name, + project: associatedProject?.packageName, + + logFilePath + }; + transferOperationForOperation.set(operation, transferableOperation); + } + + const graphMessage: IRushWorkerGraphMessage = { + type: 'graph', + value: { + operations: Array.from(transferOperationForOperation.values()) + } + }; + + parentPort?.postMessage(graphMessage); + + // Wait until aborted + await runLoop(); + } + + async function runLoop(): Promise { + let willShutdown: boolean = false; + let ready: boolean = true; + + let resolveTargets: (targets: string[]) => void; + let targetsPromise: Promise = new Promise((resolve) => { + resolveTargets = resolve; + }); + + const messageHandler: (message: IRushWorkerRequest) => void = (message: IRushWorkerRequest) => { + abortSignal.aborted = true; + + switch (message.type) { + case 'shutdown': + console.error(`Worker is shutting down.`); + willShutdown = true; + return resolveTargets([]); + case 'build': + ready = false; + return resolveTargets(message.value.targets); + } + }; + + parentPort?.on('message', messageHandler); + + const readyMessage: IRushWorkerReadyMessage = { + type: 'ready', + value: {} + }; + + // eslint-disable-next-line no-unmodified-loop-condition + while (!willShutdown) { + if (ready) { + parentPort?.postMessage(readyMessage); + } + + const targets: string[] = await targetsPromise; + // eslint-disable-next-line require-atomic-updates + ready = true; + targetsPromise = new Promise((resolve) => { + resolveTargets = resolve; + }); + + abortSignal.aborted = false; + + // Run even with empty to ensure the out of scope message gets sent. + await executeOperations(targets); + + const statesByName: [string, IStateRecord][] = []; + for (const [operation, record] of operationStates) { + const { associatedPhase, associatedProject } = operation; + if (associatedPhase && associatedProject) { + const operationKey: string = getOperationKey(associatedPhase, associatedProject); + statesByName.push([operationKey, record]); + } + } + await JsonFile.saveAsync(statesByName, stateFilePath); + } + + parentPort?.off('message', messageHandler); + parentPort?.close(); + + // Terminate + process.exit(0); + } + + function onOperationStatusChanged(record: OperationExecutionRecord): void { + const diagnostics: IDiagnostic[] = []; + const state: IStateRecord = { + stateHash: record.stateHash, + status: record.status, + duration: record.stopwatch.duration, + diagnostics + }; + operationStates.set(record.operation, state); + + if (!record.silent) { + const transferOperation: ITransferableOperation = transferOperationForOperation.get( + record.operation + )!; + + for (const diagnostic of record.stdioSummarizer.diagnostics) { + diagnostics.push(diagnostic); + } + + const operationMessage: IRushWorkerOperationsMessage = { + type: 'operations', + value: { + operations: [ + { + operation: transferOperation, + + status: record.status, + hash: record.stateHash, + duration: record.stopwatch.duration, + active: true, + diagnostics + } + ] + } + }; + + parentPort?.postMessage(operationMessage); + } + } + + function afterOperationHashes(records: Map): void { + const activeOperations: ITransferableOperationStatus[] = []; + + // Filter out skippable operations + for (const [operation, record] of records) { + const oldRecord: IStateRecord | undefined = operationStates.get(operation); + const oldHash: string | undefined = oldRecord?.stateHash; + + const transferOperation: ITransferableOperation = transferOperationForOperation.get(operation)!; + + const status: OperationStatus | undefined = oldRecord?.status; + const skip: boolean = + status === OperationStatus.NoOp || + status === OperationStatus.FromCache || + status === OperationStatus.Skipped || + status === OperationStatus.Success || + status === OperationStatus.SuccessWithWarning; + + if (oldHash && status && skip && oldHash === record.stateHash) { + // Skip things whose inputs haven't changed + const silent: boolean = status !== OperationStatus.SuccessWithWarning; + record.runner = new NullOperationRunner({ + name: record.runner.name, + result: status, + silent + }); + record.silent = silent; + + activeOperations.push({ + operation: transferOperation, + status: status, + duration: oldRecord!.duration, + hash: oldHash, + diagnostics: oldRecord!.diagnostics, + active: true + }); + } else { + operationStates.set(operation, { + stateHash: record.stateHash, + status: record.status, + duration: record.stopwatch.duration, + diagnostics: [] + }); + + activeOperations.push({ + operation: transferOperation, + status: record.status, + duration: record.stopwatch.duration, + hash: oldHash, + active: true + }); + } + } + + const activeGraphMessage: IRushWorkerOperationsMessage = { + type: 'operations', + value: { operations: activeOperations } + }; + parentPort?.postMessage(activeGraphMessage); + } + + async function executeOperations(targets: string[]): Promise { + if (!originalOptions) { + return; + } + + console.log(`Scoping build to targets:\n - ${targets.join('\n - ')}`); + + const { executionManagerOptions: originalExecutionManagerOptions } = originalOptions; + + const previousOperations: Set = new Set(includedOperations); + includedOperations.clear(); + + for (const operation of unassociatedOperations) { + includedOperations.add(operation); + } + + for (const target of targets) { + const operation: Operation | undefined = operationByKey.get(target); + if (!operation) { + console.error(`No such operation ${target}`); + } else { + includedOperations.add(operation); + } + } + + for (const operation of includedOperations) { + for (const dependency of operation.dependencies) { + includedOperations.add(dependency); + } + } + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer( + parser.rushConfiguration + ); + const executionManagerOptions: IOperationExecutionManagerOptions = { + ...originalExecutionManagerOptions, + afterOperationHashes, + onOperationStatusChanged + }; + + const createOperationsContext: ICreateOperationsContext = { + ...originalOptions.createOperationsContext, + projectChangeAnalyzer + }; + + const newExecuteOperationOptions: IExecuteOperationsOptions = { + ...originalOptions, + executionManagerOptions, + operations: includedOperations, + createOperationsContext, + abortSignal + }; + + newExecuteOperationOptions.stopwatch.reset(); + newExecuteOperationOptions.stopwatch.start(); + + const removedOperations: ITransferableOperationStatus[] = []; + for (const operation of previousOperations) { + if (!includedOperations.has(operation)) { + const record: IStateRecord | undefined = operationStates.get(operation); + const transferOperation: ITransferableOperation = transferOperationForOperation.get(operation)!; + + removedOperations.push({ + operation: transferOperation, + status: record?.status || OperationStatus.Ready, + duration: record?.duration ?? 0, + hash: record?.stateHash ?? '', + active: false + }); + } + } + + if (removedOperations.length > 0) { + const removedMessage: IRushWorkerOperationsMessage = { + type: 'operations', + value: { + operations: removedOperations + } + }; + parentPort?.postMessage(removedMessage); + } + + try { + await originalExecuteOperations.call(rawCommand, newExecuteOperationOptions); + } catch (e) { + if (!(e instanceof AlreadyReportedError)) { + throw e; + } + } + } + + rawCommand._executeOperations = interceptExecuteOperations; + } +); + +parser.execute(workerData.argv).catch(console.error); diff --git a/libraries/rush-lib/src/worker/RushWorkerHost.ts b/libraries/rush-lib/src/worker/RushWorkerHost.ts new file mode 100644 index 00000000000..e92a9aa28b0 --- /dev/null +++ b/libraries/rush-lib/src/worker/RushWorkerHost.ts @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import { Worker, SHARE_ENV } from 'worker_threads'; +import { OperationStatus } from '../logic/operations/OperationStatus'; +import { + IRushWorkerBuildMessage, + IRushWorkerResponse, + IRushWorkerShutdownMessage, + ITransferableOperation, + ITransferableOperationStatus, + PhasedCommandWorkerState +} from './RushWorker.types'; + +/** + * @alpha + */ +export interface IPhasedCommandWorkerOptions { + /** + * The working directory + */ + cwd?: string; + + /** + * Status update callback + */ + onStatusUpdates?: (operationStatus: ITransferableOperationStatus[]) => void; + + /** + * Callback invoked when worker state changes + */ + onStateChanged?: (state: PhasedCommandWorkerState) => void; +} + +const shutdownMessage: IRushWorkerShutdownMessage = { + type: 'shutdown', + value: {} +}; + +interface ISignal { + resolve: (value: T) => void; + promise: Promise; +} + +function createSignal(beforeResolve?: (value: T) => void): ISignal { + let outerResolve!: (value: T) => void; + const promise: Promise = new Promise((resolve) => { + outerResolve = (value: T) => { + beforeResolve?.(value); + resolve(value); + }; + }); + return { resolve: outerResolve, promise } as ISignal; +} + +/** + * Interface for controlling a phased command worker. The worker internally tracks the most recent state of the underlying command, + * so that each call to `updateAsync` only needs to perform operations for which the last inputs are stale. + * @alpha + */ +export class PhasedCommandWorkerController { + /** + * Overrideable, event handler for when the worker state changes + */ + public onStateChanged: (state: PhasedCommandWorkerState) => void; + /** + * Overrideable, event handler for operation status changes. + */ + public onStatusUpdates: (statuses: ITransferableOperationStatus[]) => void; + + private readonly _worker: Worker; + private _state: PhasedCommandWorkerState; + + private readonly _statusByOperation: Map; + private readonly _activeOperations: Set; + private readonly _pendingOperations: Set; + + private readonly _exitSignal: ISignal; + private readonly _graphSignal: ISignal; + + private _graph: ITransferableOperation[] | undefined = undefined; + + /** + * Creates a Worker than runs the phased commands indicated by `args`, e.g. `build --production`. + * Do not pass selection parameters (--to, --from, etc.), as scoping is handled later. + * + * @param args - The command line arguments for the worker, including the command name and any parameters. + * @param options - Configuration for the worker + * + * @alpha + */ + public constructor(args: string[], options?: IPhasedCommandWorkerOptions) { + const { + cwd, + onStatusUpdates = () => { + // Noop + }, + onStateChanged = () => { + // Noop + } + } = options ?? {}; + + this._state = 'initializing'; + this._statusByOperation = new Map(); + this._activeOperations = new Set(); + this._pendingOperations = new Set(); + + this._graphSignal = createSignal(); + + this._exitSignal = createSignal(() => { + this._updateState('exited'); + }); + + this.onStatusUpdates = onStatusUpdates; + this.onStateChanged = onStateChanged; + + const workerPath: string = path.resolve(__dirname, 'RushWorkerEntry.js'); + const worker: Worker = new Worker(workerPath, { + workerData: { + argv: args, + cwd + }, + env: SHARE_ENV, + stdout: true + }); + worker.on('exit', this._exitSignal.resolve); + this._worker = worker; + + worker.on('message', this._handleMessage); + } + + public get state(): PhasedCommandWorkerState { + return this._state; + } + + /** + * Ensures that the specified operations are built and up to date. + * @param operations - The operations to build. + * @returns The results of all operations that were built in the process. + */ + public update(operations: ITransferableOperation[]): void { + this._checkExited(); + + const targets: string[] = []; + for (const operation of operations) { + const { project, phase } = operation; + if (project && phase) { + targets.push(`${project};${phase}`); + } + } + + const buildMessage: IRushWorkerBuildMessage = { + type: 'build', + value: { + targets + } + }; + + this._updateState('updating'); + this._worker.postMessage(buildMessage); + } + + /** + * After the worker initializes, returns the list of all operations that are defined for the + * command the worker was initialzed with. + * + * @returns The list of known operations in the command for which this worker was initialized. + */ + public getGraph(): ITransferableOperation[] { + if (!this._graph) { + throw new Error(`Worker is still initializing!`); + } + this._checkExited(); + return this._graph; + } + + public getStatuses(): Iterable { + return this._statusByOperation.values(); + } + + public get activeOperationCount(): number { + return this._activeOperations.size; + } + public get pendingOperationCount(): number { + return this._pendingOperations.size; + } + + public async getGraphAsync(): Promise { + const results: void | ITransferableOperation[] = await Promise.race([ + this._exitSignal.promise, + this._graphSignal.promise + ]); + if (!results) { + throw new Error(`Worker has exited!`); + } + return results; + } + + /** + * Aborts the current execution. + * @returns A promise that resolves when the worker has aborted. + */ + public abort(): void { + return this.update([]); + } + + /** + * Aborts and shuts down the worker. + * + * @param force - Force terminates all outstanding work. + * + * @returns A promise that resolves when the worker has shut down. + */ + public async shutdownAsync(force?: boolean): Promise { + if (this._state === 'exited') { + return; + } + this._updateState('exiting'); + this._worker.postMessage(shutdownMessage); + if (force) { + await this._worker.terminate(); + } else { + await this._exitSignal.promise; + } + } + + private _updateState(state: PhasedCommandWorkerState): void { + const oldState: PhasedCommandWorkerState = this._state; + if (state !== oldState) { + this._state = state; + this.onStateChanged(state); + } + } + + private _checkExited(): void { + if (this._state === 'exited') { + throw new Error(`Worker has exited!`); + } + if (this._state === 'exiting') { + throw new Error(`Worker is exiting!`); + } + } + + private _handleMessage = (message: IRushWorkerResponse): void => { + switch (message.type) { + case 'graph': + this._graph = message.value.operations; + this._graphSignal.resolve(message.value.operations); + break; + case 'ready': + if (this._state !== 'exiting') { + this._updateState('waiting'); + } + break; + case 'operations': + for (const operation of message.value.operations) { + const name: string = operation.operation.name!; + this._statusByOperation.set(name, operation); + + if (operation.active) { + this._activeOperations.add(name); + if ( + operation.status === OperationStatus.Ready || + operation.status === OperationStatus.Executing + ) { + this._pendingOperations.add(name); + } else { + this._pendingOperations.delete(name); + } + } else { + this._activeOperations.delete(name); + this._pendingOperations.delete(name); + } + } + if (this._state !== 'exiting' && this._pendingOperations.size > 0) { + this._updateState('executing'); + } + this.onStatusUpdates(message.value.operations); + break; + } + }; +} diff --git a/libraries/rush-sdk/src/index.ts b/libraries/rush-sdk/src/index.ts index e619eca098d..b03f700b9cb 100644 --- a/libraries/rush-sdk/src/index.ts +++ b/libraries/rush-sdk/src/index.ts @@ -29,6 +29,7 @@ declare const global: NodeJS.Global & typeof globalThis & { ___rush___rushLibModule?: RushLibModuleType; ___rush___rushLibModuleFromInstallAndRunRush?: RushLibModuleType; + ___rush___workingDirectory?: string; }; function _require(moduleName: string): TResult { @@ -93,7 +94,9 @@ if (rushLibModule === undefined) { // In this case, we can use install-run-rush.js to obtain the appropriate rush-lib version for the monorepo. if (rushLibModule === undefined) { try { - const rushJsonPath: string | undefined = tryFindRushJsonLocation(process.cwd()); + const rushJsonPath: string | undefined = tryFindRushJsonLocation( + global.___rush___workingDirectory || process.cwd() + ); if (!rushJsonPath) { throw new Error( 'Unable to find rush.json in the current folder or its parent folders.\n' + diff --git a/libraries/terminal/src/StdioSummarizer.ts b/libraries/terminal/src/StdioSummarizer.ts index 74a58387842..a2dad0ee7fb 100644 --- a/libraries/terminal/src/StdioSummarizer.ts +++ b/libraries/terminal/src/StdioSummarizer.ts @@ -20,6 +20,30 @@ export interface IStdioSummarizerOptions { * @defaultValue `10` */ trailingLines?: number; + + /** + * Matcher for extracting errors and warnings out of stderr. + */ + diagnosticMatcher?: IDiagnosticMatcher; +} + +/** + * @alpha + */ +export interface IDiagnosticMatcher { + (line: string): IDiagnostic | undefined; +} + +/** + * @alpha + */ +export interface IDiagnostic { + file: string; + line: number; + column: number; + severity: 'error' | 'warning'; + message: string; + tag?: string; } /** @@ -61,6 +85,9 @@ export class StdioSummarizer extends TerminalWritable { private _abridgedOmittedLines: number = 0; private _abridgedStderr: boolean; + private readonly _diagnosticMatcher: IDiagnosticMatcher | undefined; + private readonly _diagnostics: IDiagnostic[] = []; + public constructor(options?: IStdioSummarizerOptions) { super(); @@ -70,12 +97,17 @@ export class StdioSummarizer extends TerminalWritable { this._leadingLines = options.leadingLines !== undefined ? options.leadingLines : 10; this._trailingLines = options.trailingLines !== undefined ? options.trailingLines : 10; + this._diagnosticMatcher = options.diagnosticMatcher; this._abridgedLeading = []; this._abridgedTrailing = []; this._abridgedStderr = false; } + public get diagnostics(): ReadonlyArray { + return this._diagnostics; + } + /** * Returns the summary report. * @@ -117,6 +149,13 @@ export class StdioSummarizer extends TerminalWritable { return; } + if (chunk.kind === TerminalChunkKind.Stderr) { + const diagnostic: IDiagnostic | undefined = this._diagnosticMatcher?.(chunk.text); + if (diagnostic) { + this._diagnostics.push(diagnostic); + } + } + // Did we capture enough leading lines? if (this._abridgedLeading.length < this._leadingLines) { this._abridgedLeading.push(chunk.text); diff --git a/rush.json b/rush.json index 2de14c86c24..9e36c0f427c 100644 --- a/rush.json +++ b/rush.json @@ -512,6 +512,12 @@ "reviewCategory": "libraries", "versionPolicyName": "rush" }, + { + "packageName": "@rushstack/vscode-extension", + "projectFolder": "apps/vscode-extension", + "reviewCategory": "libraries", + "shouldPublish": false + }, // "build-tests" folder (alphabetical order) { @@ -1006,7 +1012,6 @@ "reviewCategory": "libraries", "shouldPublish": true }, - // "repo-scripts" folder (alphabetical order) { "packageName": "doc-plugin-rush-stack",