diff --git a/common/changes/@rushstack/heft-lint-plugin/benjamind-heft-lint-plugin-standalone_2025-05-29-00-49.json b/common/changes/@rushstack/heft-lint-plugin/benjamind-heft-lint-plugin-standalone_2025-05-29-00-49.json new file mode 100644 index 00000000000..c519a23f9c8 --- /dev/null +++ b/common/changes/@rushstack/heft-lint-plugin/benjamind-heft-lint-plugin-standalone_2025-05-29-00-49.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-lint-plugin", + "comment": "Add support for using heft-lint-plugin standalone without a typescript phase", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-lint-plugin" +} \ No newline at end of file diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index f462ae55d1c..b8d783e01eb 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -6342,6 +6342,7 @@ packages: '@rushstack/heft': file:../../../apps/heft(@types/node@20.17.19) '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@20.17.19) semver: 7.5.4 + typescript: 5.8.2 transitivePeerDependencies: - '@types/node' dev: true diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 65d87566647..7d80b11e4e5 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": "065bbcb7e1368f568ac47b0f1ec2aa5ae4cbce31", + "pnpmShrinkwrapHash": "c7ba4d11d03d9e1b14ba33e023a043d385fa3fd8", "preferredVersionsHash": "54149ea3f01558a859c96dee2052b797d4defe68", - "packageJsonInjectedDependenciesHash": "cd56ac34ee98801d8760b47b63a5686adc8ade1d" + "packageJsonInjectedDependenciesHash": "de32ff5fe062252f7310c4fb86383790757d735c" } diff --git a/heft-plugins/heft-lint-plugin/package.json b/heft-plugins/heft-lint-plugin/package.json index b08cfea8052..a4f46bcb6b6 100644 --- a/heft-plugins/heft-lint-plugin/package.json +++ b/heft-plugins/heft-lint-plugin/package.json @@ -29,7 +29,7 @@ "@types/semver": "7.5.0", "decoupled-local-node-rig": "workspace:*", "eslint": "~8.57.0", - "tslint": "~5.20.1", - "typescript": "~5.8.2" + "typescript": "~5.8.2", + "tslint": "~5.20.1" } } diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index f20dbf30b81..030405d0b95 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -17,6 +17,8 @@ import type { ITypeScriptPluginAccessor } from '@rushstack/heft-typescript-plugin'; +import type * as TTypescript from 'typescript'; + import type { LinterBase } from './LinterBase'; import { Eslint } from './Eslint'; import { Tslint } from './Tslint'; @@ -42,6 +44,30 @@ interface ILintOptions { changedFiles?: ReadonlySet; } +function checkFix(taskSession: IHeftTaskSession, pluginOptions?: ILintPluginOptions): boolean { + let fix: boolean = + pluginOptions?.alwaysFix || taskSession.parameters.getFlagParameter(FIX_PARAMETER_NAME).value; + if (fix && taskSession.parameters.production) { + // Write this as a standard output message since we don't want to throw errors when running in + // production mode and "alwaysFix" is specified in the plugin options + taskSession.logger.terminal.writeLine( + 'Fix mode has been disabled since Heft is running in production mode' + ); + fix = false; + } + return fix; +} + +function getSarifLogPath( + heftConfiguration: HeftConfiguration, + pluginOptions?: ILintPluginOptions +): string | undefined { + const relativeSarifLogPath: string | undefined = pluginOptions?.sarifLogPath; + const sarifLogPath: string | undefined = + relativeSarifLogPath && path.resolve(heftConfiguration.buildFolderPath, relativeSarifLogPath); + return sarifLogPath; +} + export default class LintPlugin implements IHeftTaskPlugin { private readonly _lintingPromises: Promise[] = []; @@ -57,24 +83,14 @@ export default class LintPlugin implements IHeftTaskPlugin { heftConfiguration: HeftConfiguration, pluginOptions?: ILintPluginOptions ): void { + // To support standalone linting, track if we have hooked to the typescript plugin + let inTypescriptPhase: boolean = false; + // Disable linting in watch mode. Some lint rules require the context of multiple files, which // may not be available in watch mode. if (!taskSession.parameters.watch) { - let fix: boolean = - pluginOptions?.alwaysFix || taskSession.parameters.getFlagParameter(FIX_PARAMETER_NAME).value; - if (fix && taskSession.parameters.production) { - // Write this as a standard output message since we don't want to throw errors when running in - // production mode and "alwaysFix" is specified in the plugin options - taskSession.logger.terminal.writeLine( - 'Fix mode has been disabled since Heft is running in production mode' - ); - fix = false; - } - - const relativeSarifLogPath: string | undefined = pluginOptions?.sarifLogPath; - const sarifLogPath: string | undefined = - relativeSarifLogPath && path.resolve(heftConfiguration.buildFolderPath, relativeSarifLogPath); - + const fix: boolean = checkFix(taskSession, pluginOptions); + const sarifLogPath: string | undefined = getSarifLogPath(heftConfiguration, pluginOptions); // Use the changed files hook to kick off linting asynchronously taskSession.requestAccessToPluginByName( '@rushstack/heft-typescript-plugin', @@ -99,6 +115,8 @@ export default class LintPlugin implements IHeftTaskPlugin { this._lintingPromises.push(lintingPromise); } ); + // Set the flag to indicate that we are in the typescript phase + inTypescriptPhase = true; } ); } @@ -116,11 +134,61 @@ export default class LintPlugin implements IHeftTaskPlugin { // Warn since don't run the linters when in watch mode. taskSession.logger.terminal.writeWarningLine("Linting isn't currently supported in watch mode"); } else { - await Promise.all(this._lintingPromises); + if (!inTypescriptPhase) { + const fix: boolean = checkFix(taskSession, pluginOptions); + const sarifLogPath: string | undefined = getSarifLogPath(heftConfiguration, pluginOptions); + // If we are not in the typescript phase, we need to create a typescript program + // from the tsconfig file + const tsProgram: IExtendedProgram = await this._createTypescriptProgramAsync( + heftConfiguration, + taskSession + ); + const rootFiles: readonly string[] = tsProgram.getRootFileNames(); + const changedFiles: Set = new Set(); + rootFiles.forEach((rootFilePath: string) => { + const sourceFile: TTypescript.SourceFile | undefined = tsProgram.getSourceFile(rootFilePath); + changedFiles.add(sourceFile as IExtendedSourceFile); + }); + + await this._lintAsync({ + taskSession, + heftConfiguration, + fix, + sarifLogPath, + tsProgram, + changedFiles + }); + } else { + await Promise.all(this._lintingPromises); + } } }); } + private async _createTypescriptProgramAsync( + heftConfiguration: HeftConfiguration, + taskSession: IHeftTaskSession + ): Promise { + const typescriptPath: string = await heftConfiguration.rigPackageResolver.resolvePackageAsync( + 'typescript', + taskSession.logger.terminal + ); + const ts: typeof TTypescript = await import(typescriptPath); + // Create a typescript program from the tsconfig file + const tsconfigPath: string = path.resolve(heftConfiguration.buildFolderPath, 'tsconfig.json'); + const parsed: TTypescript.ParsedCommandLine = ts.parseJsonConfigFileContent( + ts.readConfigFile(tsconfigPath, ts.sys.readFile).config, + ts.sys, + path.dirname(tsconfigPath) + ); + const program: IExtendedProgram = ts.createProgram({ + rootNames: parsed.fileNames, + options: parsed.options + }) as IExtendedProgram; + + return program; + } + private async _ensureInitializedAsync( taskSession: IHeftTaskSession, heftConfiguration: HeftConfiguration