diff --git a/.vscode/launch.json b/.vscode/launch.json index b8293df3b70..62282eb0785 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -79,10 +79,10 @@ "request": "launch", "cwd": "${workspaceFolder}/vscode-extensions/rush-vscode-extension", "args": [ - "--extensionDevelopmentPath=${workspaceFolder}/vscode-extensions/rush-vscode-extension" + "--extensionDevelopmentPath=${workspaceFolder}/vscode-extensions/rush-vscode-extension/dist/vsix/unpacked" ], "outFiles": [ - "${workspaceFolder}/vscode-extensions/rush-vscode-extension/dist/**/*.js" + "${workspaceFolder}/vscode-extensions/rush-vscode-extension/**" ] // "preLaunchTask": "npm: build:watch - vscode-extensions/rush-vscode-extension" }, diff --git a/README.md b/README.md index 009488b2501..6c88b571601 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/libraries/operation-graph](./libraries/operation-graph/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Foperation-graph.svg)](https://badge.fury.io/js/%40rushstack%2Foperation-graph) | [changelog](./libraries/operation-graph/CHANGELOG.md) | [@rushstack/operation-graph](https://www.npmjs.com/package/@rushstack/operation-graph) | | [/libraries/package-deps-hash](./libraries/package-deps-hash/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fpackage-deps-hash.svg)](https://badge.fury.io/js/%40rushstack%2Fpackage-deps-hash) | [changelog](./libraries/package-deps-hash/CHANGELOG.md) | [@rushstack/package-deps-hash](https://www.npmjs.com/package/@rushstack/package-deps-hash) | | [/libraries/package-extractor](./libraries/package-extractor/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fpackage-extractor.svg)](https://badge.fury.io/js/%40rushstack%2Fpackage-extractor) | [changelog](./libraries/package-extractor/CHANGELOG.md) | [@rushstack/package-extractor](https://www.npmjs.com/package/@rushstack/package-extractor) | +| [/libraries/problem-matcher](./libraries/problem-matcher/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher.svg)](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher) | [changelog](./libraries/problem-matcher/CHANGELOG.md) | [@rushstack/problem-matcher](https://www.npmjs.com/package/@rushstack/problem-matcher) | | [/libraries/rig-package](./libraries/rig-package/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frig-package.svg)](https://badge.fury.io/js/%40rushstack%2Frig-package) | [changelog](./libraries/rig-package/CHANGELOG.md) | [@rushstack/rig-package](https://www.npmjs.com/package/@rushstack/rig-package) | | [/libraries/rush-lib](./libraries/rush-lib/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Frush-lib.svg)](https://badge.fury.io/js/%40microsoft%2Frush-lib) | | [@microsoft/rush-lib](https://www.npmjs.com/package/@microsoft/rush-lib) | | [/libraries/rush-sdk](./libraries/rush-sdk/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-sdk.svg)](https://badge.fury.io/js/%40rushstack%2Frush-sdk) | | [@rushstack/rush-sdk](https://www.npmjs.com/package/@rushstack/rush-sdk) | diff --git a/common/changes/@microsoft/rush/bmiddha-problem-matchers_2025-09-22-20-35.json b/common/changes/@microsoft/rush/bmiddha-problem-matchers_2025-09-22-20-35.json new file mode 100644 index 00000000000..dd857d248a2 --- /dev/null +++ b/common/changes/@microsoft/rush/bmiddha-problem-matchers_2025-09-22-20-35.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add `IOperationExecutionResult.problemCollector` API which matches and collects VS Code style problem matchers", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/node-core-library/bmiddha-problem-matchers_2025-09-22-20-35.json b/common/changes/@rushstack/node-core-library/bmiddha-problem-matchers_2025-09-22-20-35.json new file mode 100644 index 00000000000..9280ab7db8e --- /dev/null +++ b/common/changes/@rushstack/node-core-library/bmiddha-problem-matchers_2025-09-22-20-35.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/node-core-library", + "comment": "Add `FileError.getProblemMatcher()` which returns the problem matcher compatible with `IOperationExecutionResult.problemCollector` as well as VS Code, GitHub Actions", + "type": "patch" + } + ], + "packageName": "@rushstack/node-core-library" +} \ No newline at end of file diff --git a/common/changes/@rushstack/problem-matcher/bmiddha-problem-matchers_2025-09-25-23-26.json b/common/changes/@rushstack/problem-matcher/bmiddha-problem-matchers_2025-09-25-23-26.json new file mode 100644 index 00000000000..b5c463c66f5 --- /dev/null +++ b/common/changes/@rushstack/problem-matcher/bmiddha-problem-matchers_2025-09-25-23-26.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/problem-matcher", + "comment": "Add @rushstack/problem-matcher library to parse and use VS Code style problem matchers", + "type": "patch" + } + ], + "packageName": "@rushstack/problem-matcher" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/bmiddha-problem-matchers_2025-09-22-20-35.json b/common/changes/@rushstack/terminal/bmiddha-problem-matchers_2025-09-22-20-35.json new file mode 100644 index 00000000000..0c505d159e6 --- /dev/null +++ b/common/changes/@rushstack/terminal/bmiddha-problem-matchers_2025-09-22-20-35.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/terminal", + "comment": "Add `ProblemCollector extends TerminalWritable` API which matches and collects VS Code style problem matchers", + "type": "patch" + } + ], + "packageName": "@rushstack/terminal" +} \ No newline at end of file diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 103baa6d943..23c585145b8 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -38,6 +38,10 @@ "name": "@reduxjs/toolkit", "allowedCategories": [ "libraries", "vscode-extensions" ] }, + { + "name": "@rushstack/problem-matcher", + "allowedCategories": [ "libraries" ] + }, { "name": "@rushstack/rush-themed-ui", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 7c125ac9f67..120705e8971 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -7024,6 +7024,7 @@ packages: '@types/node': optional: true dependencies: + '@rushstack/problem-matcher': file:../../../libraries/problem-matcher(@types/node@20.17.19) '@types/node': 20.17.19 ajv: 8.13.0 ajv-draft-04: 1.0.0(ajv@8.13.0) @@ -7075,6 +7076,18 @@ packages: transitivePeerDependencies: - '@types/node' + file:../../../libraries/problem-matcher(@types/node@20.17.19): + resolution: {directory: ../../../libraries/problem-matcher, type: directory} + id: file:../../../libraries/problem-matcher + name: '@rushstack/problem-matcher' + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + dependencies: + '@types/node': 20.17.19 + file:../../../libraries/rig-package: resolution: {directory: ../../../libraries/rig-package, type: directory} name: '@rushstack/rig-package' @@ -7166,6 +7179,7 @@ packages: optional: true dependencies: '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@20.17.19) + '@rushstack/problem-matcher': file:../../../libraries/problem-matcher(@types/node@20.17.19) '@types/node': 20.17.19 supports-color: 8.1.1 diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index c0a266ac12c..078bc879c4a 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "f89693a88037554bf0c35db4f2295ef771cd2a71", + "pnpmShrinkwrapHash": "a4362af2793dd557efe7e9f005f3e2f376eb2eda", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "2fad9cbc4726f383da294e793c5b891d8775fca6" + "packageJsonInjectedDependenciesHash": "79ac135cb61506457e8d49c7ec1342d419bde3e2" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index fa35e21fefc..f0ab142cd09 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3421,6 +3421,9 @@ importers: '@rushstack/heft': specifier: 0.74.3 version: 0.74.3(@types/node@20.17.19) + '@rushstack/problem-matcher': + specifier: workspace:* + version: link:../problem-matcher '@types/fs-extra': specifier: 7.0.0 version: 7.0.0 @@ -3533,6 +3536,18 @@ importers: specifier: ~5.98.0 version: 5.98.0 + ../../../libraries/problem-matcher: + devDependencies: + '@rushstack/heft': + specifier: 0.74.3 + version: 0.74.3(@types/node@20.17.19) + decoupled-local-node-rig: + specifier: workspace:* + version: link:../../rigs/decoupled-local-node-rig + eslint: + specifier: ~9.25.1 + version: 9.25.1(supports-color@8.1.1) + ../../../libraries/rig-package: dependencies: resolve: @@ -3868,6 +3883,9 @@ importers: '@rushstack/node-core-library': specifier: workspace:* version: link:../node-core-library + '@rushstack/problem-matcher': + specifier: workspace:* + version: link:../problem-matcher supports-color: specifier: ~8.1.1 version: 8.1.1 diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index a0fc83dccdc..2abcd7652be 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -132,6 +132,7 @@ export class FileError extends Error { // @internal (undocumented) static _environmentVariableIsAbsolutePath: boolean; getFormattedErrorMessage(options?: IFileErrorFormattingOptions): string; + static getProblemMatcher(options?: Pick): IProblemPattern; readonly line: number | undefined; readonly projectFolder: string; // @internal (undocumented) @@ -587,6 +588,21 @@ export interface IPeerDependenciesMetaTable { }; } +// @public +export interface IProblemPattern { + code?: number; + column?: number; + endColumn?: number; + endLine?: number; + file?: number; + line?: number; + location?: number; + loop?: boolean; + message: number; + regexp: string; + severity?: number; +} + // @public export interface IProcessInfo { childProcessInfos: IProcessInfo[]; diff --git a/common/reviews/api/problem-matcher.api.md b/common/reviews/api/problem-matcher.api.md new file mode 100644 index 00000000000..895a1ddb3fe --- /dev/null +++ b/common/reviews/api/problem-matcher.api.md @@ -0,0 +1,55 @@ +## API Report File for "@rushstack/problem-matcher" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public +export interface IProblem { + readonly code?: string; + readonly column?: number; + readonly endColumn?: number; + readonly endLine?: number; + readonly file?: string; + readonly line?: number; + readonly matcherName: string; + readonly message: string; + readonly severity?: ProblemSeverity; +} + +// @public +export interface IProblemMatcher { + exec(line: string): IProblem | false; + flush?(): IProblem[]; + readonly name: string; +} + +// @public +export interface IProblemMatcherJson { + name: string; + pattern: IProblemPattern | IProblemPattern[]; + severity?: ProblemSeverity; +} + +// @public +export interface IProblemPattern { + code?: number; + column?: number; + endColumn?: number; + endLine?: number; + file?: number; + line?: number; + location?: number; + loop?: boolean; + message: number; + regexp: string; + severity?: number; +} + +// @public +export function parseProblemMatchersJson(problemMatchers: IProblemMatcherJson[]): IProblemMatcher[]; + +// @public +export type ProblemSeverity = 'error' | 'warning' | 'info'; + +``` diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 50b24af3f22..58ddeefaf83 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -17,6 +17,7 @@ import { HookMap } from 'tapable'; import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; +import type { IProblemCollector } from '@rushstack/terminal'; import { ITerminal } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; import { JsonNull } from '@rushstack/node-core-library'; @@ -621,6 +622,7 @@ export interface IOperationExecutionResult { readonly metadataFolderPath: string | undefined; readonly nonCachedDurationMs: number | undefined; readonly operation: Operation; + readonly problemCollector: IProblemCollector; readonly silent: boolean; readonly status: OperationStatus; readonly stdioSummarizer: StdioSummarizer; diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index 741bbd33f73..6494e673eba 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -7,6 +7,9 @@ /// import type { Brand } from '@rushstack/node-core-library'; +import type { IProblem } from '@rushstack/problem-matcher'; +import type { IProblemMatcher } from '@rushstack/problem-matcher'; +import type { IProblemMatcherJson } from '@rushstack/problem-matcher'; import { NewlineKind } from '@rushstack/node-core-library'; import { Writable } from 'stream'; import { WritableOptions } from 'stream'; @@ -142,6 +145,17 @@ export interface IPrefixProxyTerminalProviderOptionsBase { terminalProvider: ITerminalProvider; } +// @public +export interface IProblemCollector { + getProblems(): ReadonlyArray; +} + +// @public +export interface IProblemCollectorOptions extends ITerminalWritableOptions { + matcherJson?: IProblemMatcherJson[]; + matchers?: IProblemMatcher[]; +} + // @public export interface ISplitterTransformOptions extends ITerminalWritableOptions { destinations: TerminalWritable[]; @@ -287,6 +301,14 @@ export class PrintUtilities { static wrapWordsToLines(text: string, maxLineLength?: number, indentOrLinePrefix?: number | string): string[]; } +// @public +export class ProblemCollector extends TerminalWritable implements IProblemCollector { + constructor(options: IProblemCollectorOptions); + getProblems(): ReadonlyArray; + protected onClose(): void; + protected onWriteChunk(chunk: ITerminalChunk): void; +} + // @public export class RemoveColorsTextRewriter extends TextRewriter { // (undocumented) diff --git a/libraries/node-core-library/config/api-extractor.json b/libraries/node-core-library/config/api-extractor.json index 996e271d3dd..ce0b9d7f01e 100644 --- a/libraries/node-core-library/config/api-extractor.json +++ b/libraries/node-core-library/config/api-extractor.json @@ -15,5 +15,7 @@ "dtsRollup": { "enabled": true - } + }, + + "bundledPackages": ["@rushstack/problem-matcher"] } diff --git a/libraries/node-core-library/package.json b/libraries/node-core-library/package.json index a5040cc5900..7552dec688a 100644 --- a/libraries/node-core-library/package.json +++ b/libraries/node-core-library/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@rushstack/heft": "0.74.3", + "@rushstack/problem-matcher": "workspace:*", "@types/fs-extra": "7.0.0", "@types/jju": "1.4.1", "@types/resolve": "1.20.2", diff --git a/libraries/node-core-library/src/FileError.ts b/libraries/node-core-library/src/FileError.ts index d329025a8cf..4e92d214c8a 100644 --- a/libraries/node-core-library/src/FileError.ts +++ b/libraries/node-core-library/src/FileError.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import type { IProblemPattern } from '@rushstack/problem-matcher'; import { type FileLocationStyle, Path } from './Path'; import { TypeUuid } from './TypeUuid'; @@ -47,6 +48,27 @@ const uuidFileError: string = '37a4c772-2dc8-4c66-89ae-262f8cc1f0c1'; const baseFolderEnvVar: string = 'RUSHSTACK_FILE_ERROR_BASE_FOLDER'; +const unixProblemMatcherPattern: IProblemPattern = { + regexp: '^\\[[^\\]]+\\]\\s+(Error|Warning):\\s+([^:]+):(\\d+):(\\d+)\\s+-\\s+(?:\\(([^)]+)\\)\\s+)?(.*)$', + severity: 1, + file: 2, + line: 3, + column: 4, + code: 5, + message: 6 +}; + +const vsProblemMatcherPattern: IProblemPattern = { + regexp: + '^\\[[^\\]]+\\]\\s+(Error|Warning):\\s+([^\\(]+)\\((\\d+),(\\d+)\\)\\s+-\\s+(?:\\(([^)]+)\\)\\s+)?(.*)$', + severity: 1, + file: 2, + line: 3, + column: 4, + code: 5, + message: 6 +}; + /** * An `Error` subclass that should be thrown to report an unexpected state that specifically references * a location in a file. @@ -127,6 +149,24 @@ export class FileError extends Error { }); } + /** + * Get the problem matcher pattern for parsing error messages. + * + * @param options - Options for the error message format. + * @returns The problem matcher pattern. + */ + public static getProblemMatcher(options?: Pick): IProblemPattern { + const format: FileLocationStyle = options?.format || 'Unix'; + switch (format) { + case 'Unix': + return unixProblemMatcherPattern; + case 'VisualStudio': + return vsProblemMatcherPattern; + default: + throw new Error(`The FileError format "${format}" is not supported for problem matchers.`); + } + } + private _evaluateBaseFolder(): string | undefined { // Cache the sanitized environment variable. This means that we don't support changing // the environment variable mid-execution. This is a reasonable tradeoff for the benefit diff --git a/libraries/node-core-library/src/index.ts b/libraries/node-core-library/src/index.ts index 16c5c26da65..9b359180b4e 100644 --- a/libraries/node-core-library/src/index.ts +++ b/libraries/node-core-library/src/index.ts @@ -36,6 +36,7 @@ export { Executable } from './Executable'; export { type IFileErrorOptions, type IFileErrorFormattingOptions, FileError } from './FileError'; +export type { IProblemPattern } from '@rushstack/problem-matcher'; export type { INodePackageJson, IPackageJson, diff --git a/libraries/node-core-library/src/test/FileError.test.ts b/libraries/node-core-library/src/test/FileError.test.ts index 5604b70fc74..425df02ffb8 100644 --- a/libraries/node-core-library/src/test/FileError.test.ts +++ b/libraries/node-core-library/src/test/FileError.test.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import { FileError } from '../FileError'; +import type { FileLocationStyle } from '../Path'; describe(FileError.name, () => { let originalValue: string | undefined; @@ -336,3 +337,80 @@ describe(`${FileError.name} using unsupported base folder token`, () => { expect(() => error1.getFormattedErrorMessage({ format: 'Unix' })).toThrowError(); }); }); + +describe(`${FileError.name} problem matcher patterns`, () => { + let originalValue: string | undefined; + + beforeEach(() => { + originalValue = process.env.RUSHSTACK_FILE_ERROR_BASE_FOLDER; + delete process.env.RUSHSTACK_FILE_ERROR_BASE_FOLDER; + FileError._sanitizedEnvironmentVariable = undefined; + FileError._environmentVariableIsAbsolutePath = false; + }); + + afterEach(() => { + if (originalValue) { + process.env.RUSHSTACK_FILE_ERROR_BASE_FOLDER = originalValue; + } else { + delete process.env.RUSHSTACK_FILE_ERROR_BASE_FOLDER; + } + }); + + const errorStringFormats = ['Unix', 'VisualStudio'] satisfies FileLocationStyle[]; + errorStringFormats.forEach((format) => { + it(`${format} format - message without code`, () => { + const projectFolder = '/path/to/project'; + const relativePathToFile = 'path/to/file'; + const absolutePathToFile = `${projectFolder}/${relativePathToFile}`; + const lineNumber = 5; + const columnNumber = 12; + + const error1 = new FileError('message', { + absolutePath: absolutePathToFile, + projectFolder: projectFolder, + line: lineNumber, + column: columnNumber + }); + const errorMessage = error1.getFormattedErrorMessage({ format }); + const pattern = FileError.getProblemMatcher({ format }); + + const regexp = new RegExp(pattern.regexp); + const matches = regexp.exec(errorMessage); + expect(matches).toBeDefined(); + if (matches) { + expect(matches[pattern.file!]).toEqual(relativePathToFile); + expect(parseInt(matches[pattern.line!], 10)).toEqual(lineNumber); + expect(parseInt(matches[pattern.column!], 10)).toEqual(columnNumber); + expect(matches[pattern.message]).toEqual('message'); + } + }); + + it(`${format} format - message with code`, () => { + const projectFolder = '/path/to/project'; + const relativePathToFile = 'path/to/file'; + const absolutePathToFile = `${projectFolder}/${relativePathToFile}`; + const lineNumber = 5; + const columnNumber = 12; + + const error1 = new FileError('(code) message', { + absolutePath: absolutePathToFile, + projectFolder: projectFolder, + line: lineNumber, + column: columnNumber + }); + const errorMessage = error1.getFormattedErrorMessage({ format }); + const pattern = FileError.getProblemMatcher({ format }); + + const regexp = new RegExp(pattern.regexp); + const matches = regexp.exec(errorMessage); + expect(matches).toBeDefined(); + if (matches) { + expect(matches[pattern.file!]).toEqual(relativePathToFile); + expect(parseInt(matches[pattern.line!], 10)).toEqual(lineNumber); + expect(parseInt(matches[pattern.column!], 10)).toEqual(columnNumber); + expect(matches[pattern.message]).toEqual('message'); + expect(matches[pattern.code!]).toEqual('code'); + } + }); + }); +}); diff --git a/libraries/problem-matcher/.npmignore b/libraries/problem-matcher/.npmignore new file mode 100644 index 00000000000..bc349f9a4be --- /dev/null +++ b/libraries/problem-matcher/.npmignore @@ -0,0 +1,32 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/libraries/problem-matcher/LICENSE b/libraries/problem-matcher/LICENSE new file mode 100644 index 00000000000..878f9710d96 --- /dev/null +++ b/libraries/problem-matcher/LICENSE @@ -0,0 +1,24 @@ +@rushstack/problem-matcher + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/libraries/problem-matcher/README.md b/libraries/problem-matcher/README.md new file mode 100644 index 00000000000..78d048be722 --- /dev/null +++ b/libraries/problem-matcher/README.md @@ -0,0 +1,12 @@ +# @rushstack/problem-matcher + +Parse VS Code style problem matcher definitions and extract structured problem reports (errors, warnings, info) from strings. + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/main/libraries/problem-matcher/CHANGELOG.md) - Find + out what's new in the latest version +- [API Reference](https://api.rushstack.io/pages/problem-matcher/) + +`@rushstack/problem-matcher` is part of the [Rush Stack](https://rushstack.io/) family of projects. diff --git a/libraries/problem-matcher/config/api-extractor.json b/libraries/problem-matcher/config/api-extractor.json new file mode 100644 index 00000000000..996e271d3dd --- /dev/null +++ b/libraries/problem-matcher/config/api-extractor.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + "mainEntryPointFilePath": "/lib/index.d.ts", + + "apiReport": { + "enabled": true, + "reportFolder": "../../../common/reviews/api" + }, + + "docModel": { + "enabled": true, + "apiJsonFilePath": "../../../common/temp/api/.api.json" + }, + + "dtsRollup": { + "enabled": true + } +} diff --git a/libraries/problem-matcher/config/jest.config.json b/libraries/problem-matcher/config/jest.config.json new file mode 100644 index 00000000000..7c0f9ccc9d6 --- /dev/null +++ b/libraries/problem-matcher/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "decoupled-local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/libraries/problem-matcher/config/rig.json b/libraries/problem-matcher/config/rig.json new file mode 100644 index 00000000000..cc98dea43dd --- /dev/null +++ b/libraries/problem-matcher/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "decoupled-local-node-rig" +} diff --git a/libraries/problem-matcher/eslint.config.js b/libraries/problem-matcher/eslint.config.js new file mode 100644 index 00000000000..f83aea7d1b7 --- /dev/null +++ b/libraries/problem-matcher/eslint.config.js @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('decoupled-local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('decoupled-local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); + +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/libraries/problem-matcher/package.json b/libraries/problem-matcher/package.json new file mode 100644 index 00000000000..a1fac7d68ca --- /dev/null +++ b/libraries/problem-matcher/package.json @@ -0,0 +1,32 @@ +{ + "name": "@rushstack/problem-matcher", + "version": "0.0.0", + "description": "A library for parsing VS Code style problem matchers", + "main": "lib/index.js", + "typings": "dist/problem-matcher.d.ts", + "license": "MIT", + "repository": { + "url": "https://github.com/microsoft/rushstack.git", + "type": "git", + "directory": "libraries/problem-matcher" + }, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "dependencies": {}, + "devDependencies": { + "@rushstack/heft": "0.74.3", + "decoupled-local-node-rig": "workspace:*", + "eslint": "~9.25.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } +} diff --git a/libraries/problem-matcher/src/ProblemMatcher.ts b/libraries/problem-matcher/src/ProblemMatcher.ts new file mode 100644 index 00000000000..ca9ee6e4877 --- /dev/null +++ b/libraries/problem-matcher/src/ProblemMatcher.ts @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Represents the severity level of a problem. + * + * @public + */ +export type ProblemSeverity = 'error' | 'warning' | 'info'; + +/** + * Represents a problem (generally an error or warning) detected in the console output. + * + * @public + */ +export interface IProblem { + /** The name of the matcher that detected the problem. */ + readonly matcherName: string; + /** Parsed message from the problem matcher */ + readonly message: string; + /** Parsed severity level from the problem matcher */ + readonly severity?: ProblemSeverity; + /** Parsed file path from the problem matcher */ + readonly file?: string; + /** Parsed line number from the problem matcher */ + readonly line?: number; + /** Parsed column number from the problem matcher */ + readonly column?: number; + /** Parsed ending line number from the problem matcher */ + readonly endLine?: number; + /** Parsed ending column number from the problem matcher */ + readonly endColumn?: number; + /** Parsed error or warning code from the problem matcher */ + readonly code?: string; +} + +/** + * A problem matcher processes one line at a time and returns an {@link IProblem} if a match occurs. + * + * @remarks + * Multi-line matchers may keep internal state and emit on a later line; they can also optionally + * implement `flush()` to emit any buffered problems when the stream closes. + * + * @public + */ +export interface IProblemMatcher { + /** A friendly (and stable) name identifying the matcher. */ + readonly name: string; + /** + * Attempt to match a problem for the provided line of console output. + * + * @param line - A single line of text, always terminated with a newline character (\\n). + * @returns A problem if recognized, otherwise `false`. + */ + exec(line: string): IProblem | false; + /** + * Flush any buffered state and return additional problems. Optional. + */ + flush?(): IProblem[]; +} + +/** + * VS Code style problem matcher pattern definition. + * + * @remarks + * This mirrors the shape used in VS Code's `problemMatcher.pattern` entries. + * Reference: https://code.visualstudio.com/docs/editor/tasks#_defining-a-problem-matcher + * + * @public + */ +export interface IProblemPattern { + /** A regular expression used to match the problem. */ + regexp: string; + /** Match index for the file path. */ + file?: number; + /** Match index for the location. */ + location?: number; + /** Match index for the starting line number. */ + line?: number; + /** Match index for the starting column number. */ + column?: number; + /** Match index for the ending line number. */ + endLine?: number; + /** Match index for the ending column number. */ + endColumn?: number; + /** Match index for the severity level. */ + severity?: number; + /** Match index for the problem code. */ + code?: number; + /** Match index for the problem message. */ + message: number; + /** If true, the last pattern in a multi-line matcher may repeat (loop) producing multiple problems */ + loop?: boolean; +} + +/** + * Minimal VS Code problem matcher definition. + * + * @public + */ +export interface IProblemMatcherJson { + /** A friendly (and stable) name identifying the matcher. */ + name: string; + /** An optional default severity to apply if the pattern does not capture one. */ + severity?: ProblemSeverity; + /** A single pattern or an array of patterns to match. */ + pattern: IProblemPattern | IProblemPattern[]; +} + +/** + * Parse VS Code problem matcher JSON definitions into {@link IProblemMatcher} objects. + * + * @public + */ +export function parseProblemMatchersJson(problemMatchers: IProblemMatcherJson[]): IProblemMatcher[] { + const result: IProblemMatcher[] = []; + + for (const matcher of problemMatchers) { + const problemPatterns: IProblemPattern[] = Array.isArray(matcher.pattern) + ? matcher.pattern + : [matcher.pattern]; + if (problemPatterns.length === 0) { + continue; + } + + const name: string = matcher.name; + const defaultSeverity: ProblemSeverity | undefined = matcher.severity; + const compiled: ICompiledProblemPattern[] = compileProblemPatterns(problemPatterns); + + if (compiled.length === 1) { + result.push(createSingleLineMatcher(name, compiled[0], defaultSeverity)); + } else { + result.push(createMultiLineMatcher(name, compiled, defaultSeverity)); + } + } + + return result; +} + +function toNumber(text: string | undefined): number | undefined { + if (!text) { + return undefined; + } + const n: number = parseInt(text, 10); + return isNaN(n) ? undefined : n; +} + +function normalizeSeverity(raw: string | undefined): ProblemSeverity | undefined { + if (!raw) { + return undefined; + } + const lowered: string = raw.toLowerCase(); + // Support full words as well as common abbreviations (e.g. single-letter tokens) + if (lowered.indexOf('err') === 0) return 'error'; + if (lowered.indexOf('warn') === 0) return 'warning'; + if (lowered.indexOf('info') === 0) return 'info'; + return undefined; +} + +interface ICompiledProblemPattern { + re: RegExp; + spec: IProblemPattern; +} + +function compileProblemPatterns(problemPatterns: IProblemPattern[]): ICompiledProblemPattern[] { + return problemPatterns.map((problemPattern) => { + let reStr: string = problemPattern.regexp; + if (/\\r?\\n\$/.test(reStr) || /\\n\$/.test(reStr)) { + // already newline aware + } else if (reStr.length > 0 && reStr.charAt(reStr.length - 1) === '$') { + reStr = reStr.slice(0, -1) + '\\r?\\n$'; + } else { + reStr = reStr + '(?:\\r?\\n)'; + } + const re: RegExp = new RegExp(reStr); + return { re, spec: problemPattern }; + }); +} + +/** + * Shared capture structure used by both single-line and multi-line implementations. + */ +interface ICapturesMutable { + file?: string; + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + severity?: ProblemSeverity; + code?: string; + messageParts: string[]; +} + +function createEmptyCaptures(): ICapturesMutable { + return { messageParts: [] }; +} + +/** + * Apply one pattern's regex match to the (possibly accumulating) captures. + */ +function applyPatternCaptures( + spec: IProblemPattern, + reMatch: RegExpExecArray, + captures: ICapturesMutable, + defaultSeverity: ProblemSeverity | undefined +): void { + if (spec.file && reMatch[spec.file]) { + captures.file = reMatch[spec.file]; + } + + if (spec.location && reMatch[spec.location]) { + const loc: string = reMatch[spec.location]; + const parts: string[] = loc.split(/[,.:]/).filter((s) => s.length > 0); + if (parts.length === 1) { + captures.line = toNumber(parts[0]); + } else if (parts.length === 2) { + captures.line = toNumber(parts[0]); + captures.column = toNumber(parts[1]); + } else if (parts.length === 4) { + captures.line = toNumber(parts[0]); + captures.column = toNumber(parts[1]); + captures.endLine = toNumber(parts[2]); + captures.endColumn = toNumber(parts[3]); + } + } else { + if (spec.line && reMatch[spec.line]) { + captures.line = toNumber(reMatch[spec.line]); + } + if (spec.column && reMatch[spec.column]) { + captures.column = toNumber(reMatch[spec.column]); + } + } + + if (spec.endLine && reMatch[spec.endLine]) { + captures.endLine = toNumber(reMatch[spec.endLine]); + } + if (spec.endColumn && reMatch[spec.endColumn]) { + captures.endColumn = toNumber(reMatch[spec.endColumn]); + } + + if (spec.severity && reMatch[spec.severity]) { + captures.severity = normalizeSeverity(reMatch[spec.severity]) || defaultSeverity; + } else if (!captures.severity && defaultSeverity) { + captures.severity = defaultSeverity; + } + + if (spec.code && reMatch[spec.code]) { + captures.code = reMatch[spec.code]; + } + + if (spec.message && reMatch[spec.message]) { + captures.messageParts.push(reMatch[spec.message]); + } +} + +function finalizeProblem( + matcherName: string, + captures: ICapturesMutable, + defaultSeverity: ProblemSeverity | undefined +): IProblem { + return { + matcherName, + file: captures.file, + line: captures.line, + column: captures.column, + endLine: captures.endLine, + endColumn: captures.endColumn, + severity: captures.severity || defaultSeverity, + code: captures.code, + message: captures.messageParts.join('\n') + }; +} + +function createSingleLineMatcher( + name: string, + compiled: ICompiledProblemPattern, + defaultSeverity: ProblemSeverity | undefined +): IProblemMatcher { + const { re, spec } = compiled; + return { + name, + exec(line: string): IProblem | false { + const match: RegExpExecArray | null = re.exec(line); + if (!match) { + return false; + } + const captures: ICapturesMutable = createEmptyCaptures(); + applyPatternCaptures(spec, match, captures, defaultSeverity); + return finalizeProblem(name, captures, defaultSeverity); + } + }; +} + +function createMultiLineMatcher( + name: string, + compiled: ICompiledProblemPattern[], + defaultSeverity: ProblemSeverity | undefined +): IProblemMatcher { + // currentIndex points to the next pattern we expect to match. When it equals compiled.length + // and the last pattern is a loop, we are in a special "loop state" where additional lines + // should be attempted against only the last pattern to emit more problems. + let currentIndex: number = 0; + const lastSpec: IProblemPattern = compiled[compiled.length - 1].spec; + const lastIsLoop: boolean = !!lastSpec.loop; + + let captures: ICapturesMutable = createEmptyCaptures(); + + return { + name, + exec(line: string): IProblem | false { + let effectiveMatch: RegExpExecArray | null = null; + let effectiveSpec: IProblemPattern | undefined; + + // Determine matching behavior based on current state + if (currentIndex === compiled.length && lastIsLoop) { + // Loop state: only try to match the last pattern + const lastPattern: ICompiledProblemPattern = compiled[compiled.length - 1]; + effectiveMatch = lastPattern.re.exec(line); + if (!effectiveMatch) { + // Exit loop state and reset for a potential new sequence + currentIndex = 0; + captures = createEmptyCaptures(); + // Attempt to treat this line as a fresh start (pattern 0) + const first: ICompiledProblemPattern = compiled[0]; + const fresh: RegExpExecArray | null = first.re.exec(line); + if (!fresh) { + return false; + } + effectiveMatch = fresh; + effectiveSpec = first.spec; + currentIndex = compiled.length > 1 ? 1 : compiled.length; + } else { + effectiveSpec = lastPattern.spec; + // currentIndex remains compiled.length (loop state) until we decide to emit + } + } else { + // Normal multi-line progression state + const active: ICompiledProblemPattern = compiled[currentIndex]; + const reMatch: RegExpExecArray | null = active.re.exec(line); + if (!reMatch) { + // Reset and maybe attempt new start + currentIndex = 0; + captures = createEmptyCaptures(); + const { re: re0, spec: spec0 } = compiled[0]; + const restartMatch: RegExpExecArray | null = re0.exec(line); + if (!restartMatch) { + return false; + } + effectiveMatch = restartMatch; + effectiveSpec = spec0; + currentIndex = compiled.length > 1 ? 1 : compiled.length; + } else { + effectiveMatch = reMatch; + effectiveSpec = active.spec; + currentIndex++; + } + } + + applyPatternCaptures( + effectiveSpec as IProblemPattern, + effectiveMatch as RegExpExecArray, + captures, + defaultSeverity + ); + + // If we haven't matched all patterns yet (and not in loop state), wait for more lines + if (currentIndex < compiled.length) { + return false; + } + + // We have matched the full sequence (either first completion or a loop iteration) + const problem: IProblem = finalizeProblem(name, captures, defaultSeverity); + + if (lastIsLoop) { + // Stay in loop state; reset fields that accumulate per problem but retain other context (e.g., file if first pattern captured it?) + // For safety, if the last pattern provided the file each iteration we keep overwriting anyway. + captures.messageParts = []; + // Do not clear entire captures to allow preceding pattern data (e.g., summary) to persist if desirable. + } else { + currentIndex = 0; + captures = createEmptyCaptures(); + } + + return problem; + } + }; +} diff --git a/libraries/problem-matcher/src/index.ts b/libraries/problem-matcher/src/index.ts new file mode 100644 index 00000000000..22e215252c7 --- /dev/null +++ b/libraries/problem-matcher/src/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Parse VS Code style problem matcher definitions and use them to extract + * structured problem reports from strings. + * + * @packageDocumentation + */ + +export type { + ProblemSeverity, + IProblemMatcher, + IProblemMatcherJson, + IProblemPattern, + IProblem +} from './ProblemMatcher'; +export { parseProblemMatchersJson } from './ProblemMatcher'; diff --git a/libraries/problem-matcher/src/test/ProblemMatcher.test.ts b/libraries/problem-matcher/src/test/ProblemMatcher.test.ts new file mode 100644 index 00000000000..07a6b910c98 --- /dev/null +++ b/libraries/problem-matcher/src/test/ProblemMatcher.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { parseProblemMatchersJson, type IProblemMatcherJson } from '../ProblemMatcher'; + +describe('parseProblemMatchersJson - single line', () => { + it('matches a tsc style line', () => { + const matcher: IProblemMatcherJson = { + name: 'tsc-single', + pattern: { + regexp: '^(.*)\\((\\d+),(\\d+)\\): (error|warning) (TS\\d+): (.*)$', + file: 1, + line: 2, + column: 3, + severity: 4, + code: 5, + message: 6 + } + }; + + const [compiled] = parseProblemMatchersJson([matcher]); + const line = 'src/example.ts(12,34): error TS1000: Something bad happened\n'; + const prob = compiled.exec(line); + expect(prob).toBeTruthy(); + if (prob !== false) { + expect(prob.file).toBe('src/example.ts'); + expect(prob.line).toBe(12); + expect(prob.column).toBe(34); + expect(prob.code).toBe('TS1000'); + expect(prob.severity).toBe('error'); + expect(prob.message).toBe('Something bad happened'); + } + }); + + it('returns false for non-matching line', () => { + const matcher: IProblemMatcherJson = { + name: 'simple', + pattern: { + regexp: '^(.*)\\((\\d+),(\\d+)\\): error (E\\d+): (.*)$', + file: 1, + line: 2, + column: 3, + code: 4, + message: 5 + } + }; + const [compiled] = parseProblemMatchersJson([matcher]); + const notMatched = compiled.exec('This will not match\n'); + expect(notMatched).toBe(false); + }); +}); + +describe('parseProblemMatchersJson - default severity', () => { + it('applies default severity when group absent', () => { + const matcher: IProblemMatcherJson = { + name: 'default-sev', + severity: 'warning', + pattern: { + regexp: '^(.*):(\\d+):(\\d+): (W\\d+): (.*)$', + file: 1, + line: 2, + column: 3, + code: 4, + message: 5 + } + }; + const [compiled] = parseProblemMatchersJson([matcher]); + const prob = compiled.exec('lib/z.c:5:7: W123: Be careful\n'); + if (prob === false) throw new Error('Expected match'); + expect(prob.severity).toBe('warning'); + expect(prob.code).toBe('W123'); + }); +}); + +describe('parseProblemMatchersJson - multi line', () => { + it('accumulates message parts and resets after emit', () => { + const matcher: IProblemMatcherJson = { + name: 'multi-basic', + pattern: [ + { regexp: '^File: (.*)$', file: 1, message: 0 }, + { regexp: '^Pos: (\\d+),(\\d+)$', line: 1, column: 2, message: 0 }, + { regexp: '^Severity: (error|warning)$', severity: 1, message: 0 }, + { regexp: '^Msg: (.*)$', message: 1 } + ] + }; + const [compiled] = parseProblemMatchersJson([matcher]); + // Feed lines + expect(compiled.exec('File: src/a.c\n')).toBe(false); + expect(compiled.exec('Pos: 10,20\n')).toBe(false); + expect(compiled.exec('Severity: error\n')).toBe(false); + const final = compiled.exec('Msg: Something broke\n'); + if (final === false) throw new Error('Expected final match'); + expect(final.file).toBe('src/a.c'); + expect(final.line).toBe(10); + expect(final.column).toBe(20); + expect(final.severity).toBe('error'); + // Ensure message assembled (empty placeholders filtered, only last part meaningful) + expect(final.message).toBe('Something broke'); + + // Next unrelated line should not erroneously reuse old state + const no = compiled.exec('Msg: stray\n'); + expect(no).toBe(false); + }); +}); + +describe('parseProblemMatchersJson - looping last pattern', () => { + it('emits multiple problems after first sequence completion', () => { + const matcher: IProblemMatcherJson = { + name: 'looping', + severity: 'error', + pattern: [ + { regexp: '^Summary with (\\d+) issues$', message: 1 }, + { + regexp: '^(.*)\\((\\d+),(\\d+)\\): (E\\d+): (.*)$', + file: 1, + line: 2, + column: 3, + code: 4, + message: 5, + loop: true + } + ] + }; + const [compiled] = parseProblemMatchersJson([matcher]); + // Start sequence + expect(compiled.exec('Summary with 2 issues\n')).toBe(false); + const first = compiled.exec('src/a.c(1,2): E001: First\n'); + const second = compiled.exec('src/b.c(3,4): E002: Second\n'); + if (first === false || second === false) throw new Error('Expected loop matches'); + expect(first.file).toBe('src/a.c'); + expect(second.file).toBe('src/b.c'); + expect(first.code).toBe('E001'); + expect(second.code).toBe('E002'); + expect(first.severity).toBe('error'); + expect(second.severity).toBe('error'); + // Exiting loop with unrelated line resets state + expect(compiled.exec('Unrelated line\n')).toBe(false); + }); +}); + +describe('parseProblemMatchersJson - location parsing variants', () => { + it('parses (line,column) location group', () => { + const matcher: IProblemMatcherJson = { + name: 'loc-group', + pattern: { + regexp: '^(.*)\\((\\d+),(\\d+)\\): (.*)$', + file: 1, + line: 2, + column: 3, + message: 4 + } + }; + const [compiled] = parseProblemMatchersJson([matcher]); + const prob = compiled.exec('path/file.c(10,5): details here\n'); + if (prob === false) throw new Error('Expected match'); + expect(prob.file).toBe('path/file.c'); + expect(prob.line).toBe(10); + expect(prob.column).toBe(5); + }); + + it('parses explicit endLine/endColumn groups', () => { + const matcher: IProblemMatcherJson = { + name: 'end-range', + pattern: { + regexp: '^(.*)\\((\\d+),(\\d+),(\\d+),(\\d+)\\): (.*)$', + file: 1, + line: 2, + column: 3, + endLine: 4, + endColumn: 5, + message: 6 + } + }; + const [compiled] = parseProblemMatchersJson([matcher]); + const prob = compiled.exec('lib/x.c(1,2,3,4): thing\n'); + if (prob === false) throw new Error('Expected match'); + expect(prob.endLine).toBe(3); + expect(prob.endColumn).toBe(4); + }); +}); diff --git a/libraries/problem-matcher/tsconfig.json b/libraries/problem-matcher/tsconfig.json new file mode 100644 index 00000000000..1a33d17b873 --- /dev/null +++ b/libraries/problem-matcher/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/decoupled-local-node-rig/profiles/default/tsconfig-base.json" +} diff --git a/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts b/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts index f92f270585a..e25c84359b5 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { StdioSummarizer } from '@rushstack/terminal'; +import type { StdioSummarizer, IProblemCollector } from '@rushstack/terminal'; import type { OperationStatus } from './OperationStatus'; import type { Operation } from './Operation'; import type { IStopwatchResult } from '../../utilities/Stopwatch'; @@ -40,6 +40,10 @@ export interface IOperationExecutionResult { * Object used to report a summary at the end of the Rush invocation. */ readonly stdioSummarizer: StdioSummarizer; + /** + * Object used to collect problems (errors/warnings/info) encountered during the operation. + */ + readonly problemCollector: IProblemCollector; /** * The value indicates the duration of the same operation without cache hit. */ diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index b10da76af58..8534a50aa72 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -9,11 +9,12 @@ import { SplitterTransform, StderrLineTransform, StdioSummarizer, + ProblemCollector, TextRewriterTransform, Terminal, type TerminalWritable } from '@rushstack/terminal'; -import { InternalError, NewlineKind } from '@rushstack/node-core-library'; +import { InternalError, NewlineKind, FileError } from '@rushstack/node-core-library'; import { CollatedTerminal, type CollatedWriter, type StreamCollator } from '@rushstack/stream-collator'; import { OperationStatus, TERMINAL_STATUSES } from './OperationStatus'; @@ -111,6 +112,18 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera // Allow writing to this object after transforms have been closed. We clean it up manually in a finally block. preventAutoclose: true }); + public readonly problemCollector: ProblemCollector = new ProblemCollector({ + matcherJson: [ + { + name: 'rushstack-file-error-unix', + pattern: FileError.getProblemMatcher({ format: 'Unix' }) + }, + { + name: 'rushstack-file-error-visualstudio', + pattern: FileError.getProblemMatcher({ format: 'VisualStudio' }) + } + ] + }); public readonly runner: IOperationRunner; public readonly associatedPhase: IPhase; @@ -284,7 +297,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera logFileSuffix: string; } ): Promise { - const { associatedProject, stdioSummarizer } = this; + const { associatedProject, stdioSummarizer, problemCollector } = this; const { createLogFile, logFileSuffix = '' } = options; const logFilePaths: ILogFilePaths | undefined = createLogFile @@ -313,7 +326,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera // +--> stdioSummarizer const destination: TerminalWritable = projectLogWritable ? new SplitterTransform({ - destinations: [projectLogWritable, stdioSummarizer] + destinations: [projectLogWritable, stdioSummarizer, problemCollector] }) : stdioSummarizer; @@ -402,6 +415,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera if (this.isTerminal) { this._collatedWriter?.close(); this.stdioSummarizer.close(); + this.problemCollector.close(); } } } diff --git a/libraries/terminal/package.json b/libraries/terminal/package.json index 86ba223986d..af7ad1dcd44 100644 --- a/libraries/terminal/package.json +++ b/libraries/terminal/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@rushstack/node-core-library": "workspace:*", + "@rushstack/problem-matcher": "workspace:*", "supports-color": "~8.1.1" }, "devDependencies": { diff --git a/libraries/terminal/src/IProblemCollector.ts b/libraries/terminal/src/IProblemCollector.ts new file mode 100644 index 00000000000..a8d7b5bbf6b --- /dev/null +++ b/libraries/terminal/src/IProblemCollector.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IProblem } from '@rushstack/problem-matcher'; + +/** + * Collects problems (errors/warnings/info) encountered during an operation. + * + * @public + */ +export interface IProblemCollector { + /** + * Returns the collected problems. + * @throws Error if the collector is not yet closed. + */ + getProblems(): ReadonlyArray; +} diff --git a/libraries/terminal/src/ProblemCollector.ts b/libraries/terminal/src/ProblemCollector.ts new file mode 100644 index 00000000000..0efd78ec30b --- /dev/null +++ b/libraries/terminal/src/ProblemCollector.ts @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { parseProblemMatchersJson } from '@rushstack/problem-matcher'; +import type { IProblemMatcher, IProblemMatcherJson, IProblem } from '@rushstack/problem-matcher'; + +import type { ITerminalChunk } from './ITerminalChunk'; +import { type ITerminalWritableOptions, TerminalWritable } from './TerminalWritable'; +import type { IProblemCollector } from './IProblemCollector'; + +/** + * Constructor options for {@link ProblemCollector}. + * @public + */ +export interface IProblemCollectorOptions extends ITerminalWritableOptions { + /** + * The set of matchers that will be applied to each incoming line. Must contain at least one item. + */ + matchers?: IProblemMatcher[]; + /** + * VS Code style problem matcher definitions. These will be converted to + * {@link @rushstack/problem-matcher#IProblemMatcher | IProblemMatcher} definitions. + */ + matcherJson?: IProblemMatcherJson[]; +} + +/** + * A {@link TerminalWritable} that consumes line-oriented terminal output and extracts structured + * problems using one or more {@link @rushstack/problem-matcher#IProblemMatcher | IProblemMatcher} instances. + * + * @remarks + * This collector expects that each incoming {@link ITerminalChunk} represents a single line terminated + * by a `"\n"` character (for example when preceded by {@link StderrLineTransform} / `StdioLineTransform`). + * If a chunk does not end with a newline an error is thrown to surface incorrect pipeline wiring early. + * + * Call `close()` before retrieving results via getProblems. Similar to other collectors, attempting + * to read results before closure throws. + * @see getProblems + * + * @public + */ +export class ProblemCollector extends TerminalWritable implements IProblemCollector { + private readonly _matchers: IProblemMatcher[]; + private readonly _problems: IProblem[] = []; + + public constructor(options: IProblemCollectorOptions) { + super(options); + + if ( + !options || + ((!options.matchers || options.matchers.length === 0) && + (!options.matcherJson || options.matcherJson.length === 0)) + ) { + throw new Error('ProblemCollector requires at least one problem matcher.'); + } + + const fromJson: IProblemMatcher[] = options.matcherJson + ? parseProblemMatchersJson(options.matcherJson) + : []; + this._matchers = [...(options.matchers || []), ...fromJson]; + if (this._matchers.length === 0) { + throw new Error('ProblemCollector requires at least one problem matcher.'); + } + } + + /** + * {@inheritdoc IProblemCollector} + */ + public getProblems(): ReadonlyArray { + if (this.isOpen) { + throw new Error('Problems cannot be retrieved until after close() is called.'); + } + return this._problems; + } + + /** + * {@inheritdoc TerminalWritable} + */ + protected onWriteChunk(chunk: ITerminalChunk): void { + const text: string = chunk.text; + if (text.length === 0 || text[text.length - 1] !== '\n') { + throw new Error( + 'ProblemCollector expects chunks that were split into newline terminated lines. ' + + 'Invalid input: ' + + JSON.stringify(text) + ); + } + + for (const matcher of this._matchers) { + const problem: IProblem | false = matcher.exec(text); + if (problem) { + this._problems.push({ + ...problem, + matcherName: matcher.name + }); + } + } + } + + /** + * {@inheritdoc TerminalWritable} + */ + protected onClose(): void { + for (const matcher of this._matchers) { + if (matcher.flush) { + const flushed: IProblem[] = matcher.flush(); + if (flushed && flushed.length > 0) { + for (const problem of flushed) { + this._problems.push({ + ...problem, + matcherName: matcher.name + }); + } + } + } + } + } +} diff --git a/libraries/terminal/src/index.ts b/libraries/terminal/src/index.ts index febf8e81b5d..3824681927b 100644 --- a/libraries/terminal/src/index.ts +++ b/libraries/terminal/src/index.ts @@ -50,3 +50,5 @@ export { } from './PrefixProxyTerminalProvider'; export { NoOpTerminalProvider } from './NoOpTerminalProvider'; export { TerminalStreamWritable, type ITerminalStreamWritableOptions } from './TerminalStreamWritable'; +export { ProblemCollector, type IProblemCollectorOptions } from './ProblemCollector'; +export type { IProblemCollector } from './IProblemCollector'; diff --git a/libraries/terminal/src/test/ProblemCollector.test.ts b/libraries/terminal/src/test/ProblemCollector.test.ts new file mode 100644 index 00000000000..6b083e58dfb --- /dev/null +++ b/libraries/terminal/src/test/ProblemCollector.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { ProblemCollector } from '../ProblemCollector'; +import { + parseProblemMatchersJson, + type IProblemMatcher, + type IProblem, + type IProblemMatcherJson +} from '@rushstack/problem-matcher/lib/ProblemMatcher'; +import { TerminalChunkKind } from '../ITerminalChunk'; + +class ErrorLineMatcher implements IProblemMatcher { + public readonly name: string = 'errorLine'; + private readonly _regex: RegExp = /^ERROR:\s*(.*)\n$/; + public exec(line: string): IProblem | false { + const match: RegExpExecArray | null = this._regex.exec(line); + if (match) { + return { + matcherName: this.name, + message: match[1], + severity: 'error' + }; + } + return false; + } +} + +describe('ProblemCollector', () => { + it('collects a simple error line', () => { + const collector: ProblemCollector = new ProblemCollector({ + matchers: [new ErrorLineMatcher()] + }); + + collector.writeChunk({ kind: TerminalChunkKind.Stdout, text: 'hello world\n' }); + collector.writeChunk({ + kind: TerminalChunkKind.Stdout, + text: 'ERROR: something bad happened in stdout\n' + }); + collector.writeChunk({ + kind: TerminalChunkKind.Stderr, + text: 'ERROR: something bad happened in stderr\n' + }); + collector.close(); + + const problems = collector.getProblems(); + expect(problems.length).toBe(2); + expect(problems[0].message).toBe('something bad happened in stdout'); + expect(problems[0].severity).toBe('error'); + expect(problems[0].matcherName).toBe('errorLine'); + expect(problems[1].message).toBe('something bad happened in stderr'); + expect(problems[1].severity).toBe('error'); + expect(problems[1].matcherName).toBe('errorLine'); + }); +}); + +describe('VSCodeProblemMatcherAdapter - additional location formats', () => { + it('parses a location group like "line,column" in a single group', () => { + const matcherPattern = { + name: 'loc-group', + pattern: { + // Example: src/file.ts(10,5): message + // NOTE: Escape \\d so the RegExp sees the digit character class + regexp: '^(.*)\\((\\d+,\\d+)\\): (.*)$', + file: 1, + location: 2, + message: 3 + } + } satisfies IProblemMatcherJson; + + const matchers = parseProblemMatchersJson([matcherPattern]); + const collector = new ProblemCollector({ matchers }); + collector.writeChunk({ kind: TerminalChunkKind.Stdout, text: 'src/a.c(10,5): something happened\n' }); + collector.close(); + const problems = collector.getProblems(); + expect(problems.length).toBe(1); + expect(problems[0].file).toBe('src/a.c'); + expect(problems[0].line).toBe(10); + expect(problems[0].column).toBe(5); + expect(problems[0].message).toContain('something happened'); + }); + + it('parses explicit endLine and endColumn groups', () => { + const matcherPattern = { + name: 'end-range', + pattern: { + // Example: file(10,5,12,20): message + regexp: '^(.*)\\((\\d+),(\\d+),(\\d+),(\\d+)\\): (.*)$', + file: 1, + // We intentionally do NOT use "location" here; use explicit groups + line: 2, + column: 3, + endLine: 4, + endColumn: 5, + message: 6 + } + } satisfies IProblemMatcherJson; + + const matchers = parseProblemMatchersJson([matcherPattern]); + const collector = new ProblemCollector({ matchers }); + collector.writeChunk({ kind: TerminalChunkKind.Stdout, text: 'lib/x.c(10,5,12,20): multi-line issue\n' }); + collector.close(); + const problems = collector.getProblems(); + expect(problems.length).toBe(1); + expect(problems[0].file).toBe('lib/x.c'); + expect(problems[0].line).toBe(10); + expect(problems[0].column).toBe(5); + expect(problems[0].endLine).toBe(12); + expect(problems[0].endColumn).toBe(20); + expect(problems[0].message).toContain('multi-line issue'); + }); +}); + +describe('VSCodeProblemMatcherAdapter', () => { + it('converts and matches a single-line pattern', () => { + const matcherPattern = { + name: 'tsc-like', + pattern: { + // Example: src/file.ts(10,5): error TS1005: ';' expected + regexp: '^(.*)\\((\\d+),(\\d+)\\): (error|warning) (TS\\d+): (.*)$', + file: 1, + line: 2, + column: 3, + severity: 4, + code: 5, + message: 6 + } + } satisfies IProblemMatcherJson; + + const matchers = parseProblemMatchersJson([matcherPattern]); + const collector = new ProblemCollector({ matchers }); + collector.writeChunk({ + kind: TerminalChunkKind.Stderr, + text: "src/file.ts(10,5): error TS1005: ' ; ' expected\n" + }); + collector.close(); + const probs = collector.getProblems(); + expect(probs.length).toBe(1); + expect(probs[0].file).toBe('src/file.ts'); + expect(probs[0].line).toBe(10); + expect(probs[0].column).toBe(5); + expect(probs[0].code).toBe('TS1005'); + expect(probs[0].severity).toBe('error'); + }); + + it('converts and matches a multi-line pattern', () => { + const matcherPattern = { + name: 'multi', + pattern: [ + { + // First line: File path + regexp: '^In file (.*)$', + file: 1, + message: 1 // placeholder, will collect below as well + }, + { + // Second line: location + regexp: '^Line (\\d+), Col (\\d+)$', + line: 1, + column: 2, + message: 1 + }, + { + // Third line: severity and message + regexp: '^(error|warning): (.*)$', + severity: 1, + message: 2 + } + ] + } satisfies IProblemMatcherJson; + const matchers = parseProblemMatchersJson([matcherPattern]); + const collector = new ProblemCollector({ matchers }); + collector.writeChunk({ kind: TerminalChunkKind.Stdout, text: 'In file src/foo.c\n' }); + collector.writeChunk({ kind: TerminalChunkKind.Stdout, text: 'Line 42, Col 7\n' }); + collector.writeChunk({ kind: TerminalChunkKind.Stdout, text: 'error: something bad happened\n' }); + collector.close(); + const problems = collector.getProblems(); + expect(problems.length).toBe(1); + expect(problems[0].file).toBe('src/foo.c'); + expect(problems[0].line).toBe(42); + expect(problems[0].column).toBe(7); + expect(problems[0].severity).toBe('error'); + expect(problems[0].message).toContain('something bad'); + }); + + it('handles a multi-line pattern whose last pattern loops producing multiple problems', () => { + // Simulate a tool summary line followed by multiple TypeScript style error lines. + // The last pattern uses `loop: true` so each subsequent matching line yields a problem. + const matcherPattern = { + name: 'ts-loop-errors', + severity: 'error', + pattern: [ + { + // Summary line: Encountered 6 errors + regexp: '^Encountered (\\d+) errors$', + // Must supply a message group per interface; we capture the count but don't rely on it. + message: 1 + }, + { + // Error detail lines (one per problem): + // [build:typescript] path/to/file.ts:9:3 - (TS2578) Message text + regexp: '^\\s+\\[build:typescript\\]\\s+(.*):(\\d+):(\\d+) - \\((TS\\d+)\\) (.*)$', + file: 1, + line: 2, + column: 3, + code: 4, + message: 5, + loop: true + } + ] + } satisfies IProblemMatcherJson; + + const errorLines: string[] = [ + 'Encountered 6 errors', + ' [build:typescript] vscode-extensions/debug-certificate-manager-vscode-extension/src/certificates.ts:9:3 - (TS2578) Unused @ts-expect-error directive.', + ' [build:typescript] vscode-extensions/debug-certificate-manager-vscode-extension/src/certificates.ts:11:3 - (TS2578) Unused @ts-expect-error directive.', + ' [build:typescript] vscode-extensions/debug-certificate-manager-vscode-extension/src/certificates.ts:19:3 - (TS2578) Unused @ts-expect-error directive.', + ' [build:typescript] vscode-extensions/debug-certificate-manager-vscode-extension/src/certificates.ts:24:3 - (TS2578) Unused @ts-expect-error directive.', + ' [build:typescript] vscode-extensions/debug-certificate-manager-vscode-extension/src/certificates.ts:26:3 - (TS2578) Unused @ts-expect-error directive.', + ' [build:typescript] vscode-extensions/debug-certificate-manager-vscode-extension/src/certificates.ts:34:3 - (TS2578) Unused @ts-expect-error directive.' + ]; + + const matchers = parseProblemMatchersJson([matcherPattern]); + const collector = new ProblemCollector({ matchers }); + for (const line of errorLines) { + collector.writeChunk({ kind: TerminalChunkKind.Stdout, text: line + '\n' }); + } + collector.close(); + + const problems = collector.getProblems(); + expect(problems.length).toBe(6); + // First problem's message will include the count line plus the first error message (due to how captures accumulate before loop resets) + expect(problems[0].message).toContain('Unused @ts-expect-error directive.'); + // Ensure every problem has expected properties and severity propagated from matcher default. + for (let i = 0; i < problems.length; i++) { + const p = problems[i]; + expect(p.file).toContain( + 'vscode-extensions/debug-certificate-manager-vscode-extension/src/certificates.ts' + ); + expect(p.line).toBeGreaterThan(0); + expect(p.column).toBe(3); // All sample lines have column 3 + expect(p.code).toBe('TS2578'); + expect(p.severity).toBe('error'); + expect(p.message).toContain('Unused @ts-expect-error directive.'); + } + }); + + it('handles looped pattern with per-line severity token', () => { + const matcherPattern = { + name: 'loop-with-severity', + pattern: [ + { + regexp: '^Start Problems$', + message: 0 // we will just push empty placeholder + }, + { + // e.g. "Error path/file.ts(10,5): code123: Something happened" + regexp: '^(Error|Warning|Info) (.*)\\((\\d+),(\\d+)\\): (\\w+): (.*)$', + severity: 1, // E -> error, W -> warning (normalization should map) + file: 2, + line: 3, + column: 4, + code: 5, + message: 6, + loop: true + } + ] + } satisfies IProblemMatcherJson; + + const lines = [ + 'Error lib/a.ts(10,5): CODE100: First thing', + 'Warning lib/b.ts(20,1): CODE200: Second thing', + 'Error lib/c.ts(30,9): CODE300: Third thing', + 'Info lib/d.ts(40,2): CODE400: Fourth thing' + ]; + + const matchers = parseProblemMatchersJson([matcherPattern]); + const collector = new ProblemCollector({ matchers }); + collector.writeChunk({ kind: TerminalChunkKind.Stdout, text: 'Start Problems\n' }); + for (const l of lines) collector.writeChunk({ kind: TerminalChunkKind.Stdout, text: l + '\n' }); + collector.close(); + const problems = collector.getProblems(); + expect(problems.length).toBe(4); + expect(problems.map((p) => p.severity)).toEqual(['error', 'warning', 'error', 'info']); + expect(problems.map((p) => p.code)).toEqual(['CODE100', 'CODE200', 'CODE300', 'CODE400']); + expect(problems[0].file).toBe('lib/a.ts'); + expect(problems[1].file).toBe('lib/b.ts'); + expect(problems[2].file).toBe('lib/c.ts'); + expect(problems[3].file).toBe('lib/d.ts'); + }); +}); diff --git a/rush.json b/rush.json index 95ac0e84ba0..80a47febb84 100644 --- a/rush.json +++ b/rush.json @@ -1140,6 +1140,13 @@ "reviewCategory": "libraries", "shouldPublish": true }, + { + "packageName": "@rushstack/problem-matcher", + "projectFolder": "libraries/problem-matcher", + "reviewCategory": "libraries", + "shouldPublish": true, + "decoupledLocalDependencies": ["@rushstack/heft"] + }, { "packageName": "@rushstack/heft-config-file", "projectFolder": "libraries/heft-config-file", diff --git a/vscode-extensions/rush-vscode-extension/package.json b/vscode-extensions/rush-vscode-extension/package.json index 765e397f718..e5624cfb2d9 100644 --- a/vscode-extensions/rush-vscode-extension/package.json +++ b/vscode-extensions/rush-vscode-extension/package.json @@ -217,6 +217,46 @@ } } ], + "problemMatchers": [ + { + "name": "rushstack-file-error-unix", + "owner": "typescript", + "source": "rushstack", + "fileLocation": [ + "relative", + "${cwd}" + ], + "applyTo": "allDocuments", + "pattern": { + "regexp": "^\\[[^\\]]+\\]\\s+(Error|Warning):\\s+([^:]+):(\\d+):(\\d+)\\s+-\\s+(?:\\(([^)]+)\\)\\s+)?(.*)$", + "severity": 1, + "file": 2, + "line": 3, + "column": 4, + "code": 5, + "message": 6 + } + }, + { + "name": "rushstack-file-error-visualstudio", + "owner": "typescript", + "source": "rushstack", + "fileLocation": [ + "relative", + "${cwd}" + ], + "applyTo": "allDocuments", + "pattern": { + "regexp": "^\\[[^\\]]+\\]\\s+(Error|Warning):\\s+([^\\(]+)\\((\\d+),(\\d+)\\)\\s+-\\s+(?:\\(([^)]+)\\)\\s+)?(.*)$", + "severity": 1, + "file": 2, + "line": 3, + "column": 4, + "code": 5, + "message": 6 + } + } + ], "views": { "rushstack": [ { diff --git a/vscode-extensions/rush-vscode-extension/src/providers/TaskProvider.ts b/vscode-extensions/rush-vscode-extension/src/providers/TaskProvider.ts index d81b450cbde..fc96d2ffe1a 100644 --- a/vscode-extensions/rush-vscode-extension/src/providers/TaskProvider.ts +++ b/vscode-extensions/rush-vscode-extension/src/providers/TaskProvider.ts @@ -47,6 +47,8 @@ export class RushTaskProvider implements vscode.TaskProvider { public async executeTaskAsync(definition: T): Promise { let task: vscode.Task | undefined; + // problem matchers are defined in extension manifest + const problemMatchers: string[] = ['$rushstack-file-error-unix', '$rushstack-file-error-visualstudio']; switch (definition.type) { case 'rush-project-script': { const { cwd, displayName, command } = definition; @@ -62,7 +64,8 @@ export class RushTaskProvider implements vscode.TaskProvider { 'rushx', new vscode.ShellExecution(`rushx ${command}`, { cwd - }) + }), + problemMatchers ); break; } @@ -80,7 +83,8 @@ export class RushTaskProvider implements vscode.TaskProvider { 'rush', new vscode.ShellExecution(`rush ${command} ${args.join(' ')}`, { cwd - }) + }), + problemMatchers ); break; }