UNPKG

@halospv3/hce.shared-config

Version:

Automate commit message quality, changelogs, and CI/CD releases. Its `main` entry point is a Semantic Release config. Functions and classes are exposed for customization. An ESLint config, a Commitlint config, and addl. resources for .NET projects are als

865 lines (820 loc) 34.7 kB
import { type, type Scope, type Type } from 'arktype'; import { warn } from 'node:console'; import { hash } from 'node:crypto'; import { type Dirent } from 'node:fs'; import { readdir, realpath, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { setTimeout } from 'node:timers/promises'; import { CaseInsensitiveMap } from '../CaseInsensitiveMap.ts'; import debug from '../debug.ts'; import { execAsync } from '../utils/execAsync.ts'; import { isError } from '../utils/isError.ts'; import { MSBuildProjectProperties } from './MSBuildProjectProperties.ts'; import { NPPGetterNames, NugetProjectProperties, } from './NugetProjectProperties.ts'; const debug_MSBP = debug.extend('MSBuildProject'); debug_MSBP.enabled = debug.enabled; const debug_MSBP_PPTMSBP = debug_MSBP.extend('PackableProjectsToMSBuildProjects'); debug_MSBP_PPTMSBP.enabled = debug.enabled; const debug_MSBP_Evaluate = debug_MSBP.extend('Evaluate'); debug_MSBP_Evaluate.enabled = debug.enabled; export type TemporaryDirectoryNamespace_Unix = `${ReturnType<typeof tmpdir>}/HCE.Shared/.NET/`; export type TemporaryDirectoryNamespace_Win = `${ReturnType<typeof tmpdir>}\\HCE.Shared\\.NET\\`; const temporaryDirectoryNamespace = path.join(tmpdir(), 'HCE.Shared', '.NET') as TemporaryDirectoryNamespace_Unix | TemporaryDirectoryNamespace_Win; type typeof_ArtifactsPath = | (`${TemporaryDirectoryNamespace_Unix}${string}/${string}/`) | (`${TemporaryDirectoryNamespace_Win}${string}\\${string}/`); /** * See [MSBuild well-known item metadata](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-well-known-item-metadata). * Additional string-type properties may be present (e.g. `{ SubType: "designer" }`). */ const interface_ItemMetadataBuiltIn = type({ '[string]': 'string', /** @example "c:\\source\\repos\\ConsoleApp1\\ConsoleApp1\\bin\\Debug\\net6.0\\ConsoleApp1.dll" */ Identity: 'string', /** @example "Designer" */ 'SubType?': 'string', /** @example ".NETCoreApp" */ 'TargetFrameworkIdentifier?': 'string', 'TargetPlatformMoniker?': 'string', /** @example "c:\\source\\repos\\ConsoleApp1\\ConsoleApp1\\obj\\Debug\\net6.0\\ConsoleApp1.csproj.CopyComplete" */ 'CopyUpToDateMarker?': 'string', 'TargetPlatformIdentifier?': 'string', /** @example "6.0" */ 'TargetFrameworkVersion?': 'string', /** @example "c:\\source\\repos\\ConsoleApp1\\ConsoleApp1\\obj\\Debug\\net6.0\\ref\\ConsoleApp1.dll" */ 'ReferenceAssembly?': 'string', /** @example "c:\\source\\repos\\ConsoleApp1\\ConsoleApp1\\bin\\Debug\\net6.0\\ConsoleApp1.dll" */ FullPath: 'string', /** @example "c:\\" */ RootDir: 'string', /** @example "ConsoleApp1" */ Filename: 'string', /** @example ".dll" */ Extension: 'string', /** @example "c:\\source\\repos\\ConsoleApp1\\ConsoleApp1\\bin\\Debug\\net6.0\\" */ RelativeDir: 'string', /** @example "source\\repos\\ConsoleApp1\\ConsoleApp1\\bin\\Debug\\net6.0\\" */ Directory: 'string', RecursiveDir: 'string', /** @example "2023-11-30 13:38:06.5084339" */ ModifiedTime: 'string', /** @example "2023-11-30 13:38:06.9308716" */ CreatedTime: 'string', /** @example "2023-11-30 13:38:06.9318732" */ AccessedTime: 'string', /** @example "C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\MSBuild\\Current\\Bin\\amd64\\Microsoft.Common.CurrentVersion.targets" */ DefiningProjectFullPath: 'string', /** @example "C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\MSBuild\\Current\\Bin\\amd64\\" */ DefiningProjectDirectory: 'string', /** @example "Microsoft.Common.CurrentVersion" */ DefiningProjectName: 'string', /** @example ".targets" */ DefiningProjectExtension: 'string', }); const targetSuccess = type({ Result: '\'Success\'', Items: interface_ItemMetadataBuiltIn.array(), }); const targetFailure = type({ Result: '\'Failure\'', Items: 'never[]', }); const msbuildEvaluationOutput: Type<{ Properties?: Record<string, string> | undefined; Items?: Record<string, { [x: string]: string | undefined; Identity: string; FullPath: string; RootDir: string; Filename: string; Extension: string; RelativeDir: string; Directory: string; RecursiveDir: string; ModifiedTime: string; CreatedTime: string; AccessedTime: string; DefiningProjectFullPath: string; DefiningProjectDirectory: string; DefiningProjectName: string; DefiningProjectExtension: string; SubType?: string; TargetFrameworkIdentifier?: string | undefined; TargetPlatformMoniker?: string | undefined; CopyUpToDateMarker?: string | undefined; TargetPlatformIdentifier?: string | undefined; TargetFrameworkVersion?: string | undefined; ReferenceAssembly?: string | undefined; }[]> | undefined; TargetResults?: Record<string, { Result: 'Success'; Items: { [x: string]: string | undefined; Identity: string; FullPath: string; RootDir: string; Filename: string; Extension: string; RelativeDir: string; Directory: string; RecursiveDir: string; ModifiedTime: string; CreatedTime: string; AccessedTime: string; DefiningProjectFullPath: string; DefiningProjectDirectory: string; DefiningProjectName: string; DefiningProjectExtension: string; SubType?: string | undefined; TargetFrameworkIdentifier?: string | undefined; TargetPlatformMoniker?: string | undefined; CopyUpToDateMarker?: string | undefined; TargetPlatformIdentifier?: string | undefined; TargetFrameworkVersion?: string | undefined; ReferenceAssembly?: string | undefined; }[]; } | { Result: 'Failure'; Items: never[]; }> | undefined; }> = type({ 'Properties?': type({ '[string]': 'string' }), 'Items?': type({ '[string]': interface_ItemMetadataBuiltIn.array() }), 'TargetResults?': type({ '[string]': targetSuccess.or(targetFailure) }), }); export class MSBuildEvaluationOutput { /** * The specified properties and their values as evaluated by MSBuild Core. * `-getProperty:{propertyName,...}` */ Properties?: typeof msbuildEvaluationOutput.infer.Properties; /** * The specified items and their values and associated metadata as evaluated * by MSBuild Core. * `-getItem:{itemName,...}` */ Items?: typeof msbuildEvaluationOutput.infer.Items; /** * The specified Targets and their output values as evaluated by MSBuild * Core. * `-getTargetResult:{targetName,...}` */ TargetResults?: typeof msbuildEvaluationOutput.infer.TargetResults; /** * @param rawMSBuildEvaluation The output of a CLI MSBuild project evaluation. * May be the UTF-8 string-encoded JSON or the object decoded from that JSON. */ constructor(rawMSBuildEvaluation: Parameters<typeof JSON.parse>[0] | Parameters<typeof msbuildEvaluationOutput.from>[0]) { /** `.assert` instead of `.from` to allow `unknown` JSON.parse return type */ const knownObject = msbuildEvaluationOutput.assert(typeof rawMSBuildEvaluation === 'string' ? JSON.parse(rawMSBuildEvaluation) : rawMSBuildEvaluation); this.Properties = knownObject.Properties; this.Items = knownObject.Items; this.TargetResults = knownObject.TargetResults; } } export const EvaluationOptions: Type<{ FullName: string; /** * @see {@link NugetProjectProperties} * @description * User-defined Properties and their values. * `{ Configuration: "Release" }` will cause the MSBuild to first set the * Configuration property to Release before evaluating the project * or the project's Target(s). * ```txt * -property:<n>=<v> Set or override these project-level properties. <n> is * the property name, and <v> is the property value. Use a * semicolon or a comma to separate multiple properties, or * specify each property separately. (Short form: -p) * Example: * -property:WarningLevel=2;OutDir=bin\Debug\ * ``` */ Property: Partial<{ -readonly [P in keyof NugetProjectProperties]: NugetProjectProperties[P] }>; Targets: readonly string[] | string[]; GetItem: readonly string[] | string[]; GetProperty: readonly string[] | string[]; GetTargetResult: readonly string[] | string[]; }> = Object.freeze( type({ /** * The project file's full path. */ FullName: 'string', /** * User-defined Properties and their values. * { Configuration: "Release" } will cause the MSBuild to first set the * Configuration property to Release before evaluating the project * or the project's Target(s). * ```txt * -property:<n>=<v> Set or override these project-level properties. <n> is * the property name, and <v> is the property value. Use a * semicolon or a comma to separate multiple properties, or * specify each property separately. (Short form: -p) * Example: * -property:WarningLevel=2;OutDir=bin\Debug\ * ``` */ Property: type({ '[string]': 'string' }) .as<{ -readonly [P in keyof NugetProjectProperties]: NugetProjectProperties[P] }>() .partial(), /** * The MSBuild Targets to run for evaluation. ["Pack"] is recommended. * Property values may be changed by Targets such as those provided by * dependencies. * * ```txt * -target:<targets> Build these targets in this project. Use a semicolon or a * comma to separate multiple targets, or specify each * target separately. (Short form: -t) * Example: * -target:Resources;Compile * ``` * @default [] */ Targets: type.string.array().readonly().or('string[]'), /** * MSBuild Items to evaluate. `["Compile"]` will result in the MSBuild output * including {@link MSBuild} */ GetItem: type.string.array().readonly().or('string[]'), GetProperty: type.string.array().readonly().or('string[]'), GetTargetResult: type.string.array().readonly().or('string[]'), }), ); export class MSBuildProject { /** * Properties for multi-targeting `dotnet publish` outputs. * These are included in {@link NPPGetterNames.InstanceGettersRecursive}. */ public static readonly MatrixProperties: readonly string[] = Object.freeze([ 'TargetFramework', 'TargetFrameworks', 'RuntimeIdentifier', 'RuntimeIdentifiers', ]); /** * @param projectPath The full path of the project file or its directory. A * relative path may be passed, but will resolve relative to the current * working directory. * @param includeNonPublic Include conventionally internal/private MSBuild * targets in the result. * @returns A string array of the project's MSBuild targets. * @todo consider 'file' of -targets[:file] * Prints a list of available targets without executing the * actual build process. By default the output is written to * the console window. If the path to an output file * is provided that will be used instead. * (Short form: -ts) * Example: * -ts:out.txt */ static async GetTargets( projectPath: string, // eslint-disable-next-line unicorn/consistent-boolean-name includeNonPublic = false, ): Promise<string[]> { const stdPair = await execAsync(`dotnet msbuild ${projectPath} -targets`, true); const targets = stdPair.stdout .split('\n') .filter((v, index) => v !== '' && index !== 0) .map(v => v.replaceAll('\r', '')) .sort((a, b) => a.localeCompare(b)); return includeNonPublic ? targets : targets.filter(v => !v.startsWith('_')); } /** * Evaluate {@link Items}, {@link Properties}, and {@link TargetResults}, * returning them as an instance of {@link MSBuildProject}.\ * Note: MSBuild will probably fail if Restore is skipped and another * target is specified. If you choose Pack, you must do ['Restore', 'Pack']. * @param options The result of {@link EvaluationOptions.from}. * @returns A promised {@link MSBuildProject} instance. * @throws {Error} if the exec command fails -OR- the JSON parse fails -OR- * MSBuildProject's constructor fails. * @see {@link PackableProjectsToMSBuildProjects} for most use-cases. */ public static async Evaluate( options: typeof EvaluationOptions.inferOut, ): Promise<MSBuildProject> { if ( options.GetProperty.length === 0 && options.GetItem.length === 0 && options.GetTargetResult.length === 0 ) { throw new Error( 'No MSBuild Property, Item, or TargetResult queries were provided.', ); } // We append this to ArtifactsPath for parallel evaluations const shortHashName = `options_SHA3-256_${ hash( 'SHA3-256', JSON.stringify(options), ).slice(0, 7) }`; const debug_MSBP_Evaluate_hashed = debug_MSBP_Evaluate.extend(shortHashName); debug_MSBP_Evaluate_hashed.enabled = debug.enabled; // override `ArtifactsPath` instead of `IntermediateOutputPath` and `OutputPath` to resolve NETSDK1047, MC3074 /** Update {@link typeof_ArtifactsPath} if this changes */ options.Property.ArtifactsPath = path.join( temporaryDirectoryNamespace, path.basename(options.FullName, path.extname(options.FullName)), shortHashName, ) + '/'; // reminder: args containing spaces and semi-colons MUST be quote-enclosed! options.FullName = MSBuildProjectProperties.GetFullPath(options.FullName); // disable GeneratePackageOnBuild so Pack can succeed when Build hasn't been run options.Property.GeneratePackageOnBuild = 'false'; const _pairs = Object.entries<string>(options.Property).filter(p => typeof p[1] === 'string') as [ ['ArtifactsPath', typeof_ArtifactsPath], ['GeneratePackageOnBuild', 'false'], ...[string, string][], ]; const string_target = options.Targets.length === 0 ? '' : `"-t:${options.Targets.join(';')}"`; const string_getItem = options.GetItem.length === 0 ? '' : `"-getItem:${options.GetItem.join(',')}"`; const string_getProperty = options.GetProperty.length === 0 ? '' : `"-getProperty:${options.GetProperty.join(',')}"`; const string_getTargetResult = options.GetTargetResult.length === 0 ? '' : `"-getTargetResult:${options.GetTargetResult.join(',')}"`; const string_property_array: string[] = _pairs.map(([key, value]) => `"-p:${key}=${value}"`); const isTargetPack = string_target.toLocaleLowerCase().replaceAll('"', '') == `-t:pack`; const commandLine = [ 'dotnet', isTargetPack ? 'pack' : 'msbuild', `"${options.FullName}"`, '-restore', isTargetPack ? '' : string_target, string_getItem, string_getProperty, string_getTargetResult, ...string_property_array, ] .filter(v => v !== '') .join(' '); const output = await loopTryDotnetCommand({ commandLine, customDebugger: debug_MSBP_Evaluate_hashed, projectName: path.basename(options.FullName, path.extname(options.FullName)), taskVerb: 'evaluate', timeoutMilliseconds: 360_000, }); // todo: consider -getResultOutputFile:file // Redirect output from get* into a file. // // Example: // -getProperty:Bar -getResultOutputFile:Biz.txt // This writes the value of property Bar into Biz.txt. /** * The following issues have triggered this code path: * - BaseIntermediateOutputPath must use Unix path separators ('/') on all * platforms. Even Windows. Otherwise, MSBuild/dotnet will error-exit with * "The BaseIntermediateOutputPath must end with a trailing slash". */ if (output.stdout.startsWith('MSBuild version')) { warn(output.stdout); throw new Error( 'dotnet msbuild was expected to output JSON, but output its version header instead.', ); } let rawOutput: ConstructorParameters<typeof MSBuildEvaluationOutput>[0]; if (output.stdout.startsWith('{')) { /** stdout is JSON string */ rawOutput = output.stdout; } else if (options.GetProperty.length > 0 && options.GetProperty[0] !== undefined) { rawOutput = { Properties: { [options.GetProperty[0]]: String(JSON.parse(output.stdout)), }, }; } else { throw new Error('Dotnet/MSBuild evaluation output is not a string nor JSON object or array.'); } const evaluation = new MSBuildEvaluationOutput(rawOutput); debug_MSBP_Evaluate_hashed(`Getting MSBuild Targets of "${options.FullName}"...`); const projTargets = await this.GetTargets(options.FullName); debug_MSBP_Evaluate_hashed(`Returning new MSBuildProject instance for "${options.FullName}"...`); return new MSBuildProject({ fullPath: options.FullName, projTargets, evaluation, }); } /** * Evaluate multiple project paths with some default Evaluate options. * @async * @param projectsToPackAndPush An array of MSBuild projects' full file * paths. If a path is a directory, files in that directory are filtered for * `.csproj`, `.fsproj`, and `.vbproj` project files. * See https://github.com/dotnet/sdk/blob/497f334b2862bdf98b30c00ede2fd259ea5f624d/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluationResult.cs#L19-L32.\ * @returns A promised array of {@link MSBuildProject} instances. * All known MSBuild and NuGet properties are evaluated. * If applicable, a project's "Pack" target is evaluated. */ public static async PackableProjectsToMSBuildProjects( projectsToPackAndPush: string[], ): Promise<Promise<MSBuildProject>[]> { debug_MSBP_PPTMSBP('Mapping projects to Dirent instances...'); const directoryEntriesPromise = await toDirectoryEntries(typeof projectsToPackAndPush === 'string' ? [projectsToPackAndPush] : projectsToPackAndPush); debug_MSBP_PPTMSBP('Converting Dirent instances to MSBuildProject instances...'); const projectPromises: Promise<MSBuildProject>[] = directoryEntriesPromise.map(element => convertDirentToMSBuildProject(element)); return projectPromises; /** * Map an array of filesystem paths to {@link Dirent} instances representing project files. * @param projectsToPackAndPush An array of MSBuild projects' full file * paths. If a path is a directory, files in that directory are filtered for * `.csproj`, `.fsproj`, and `.vbproj` project files. See * https://github.com/dotnet/sdk/blob/497f334b2862bdf98b30c00ede2fd259ea5f624d/src/Cli/dotnet/Commands/New/MSBuildEvaluation/MSBuildEvaluationResult.cs#L19-L32.\ * @returns An promised array of Dirent instances for discovered project files. */ async function toDirectoryEntries( projectsToPackAndPush: string[], ): Promise<Dirent[]> { const directoryEntries: (Dirent | Dirent[])[] = await Promise.all( projectsToPackAndPush.map(async (proj) => { debug_MSBP_PPTMSBP(`Getting absolute path of "${proj}"...`); proj = await realpath(makeAbsolute(proj)); debug_MSBP_PPTMSBP(`Getting filesystem stats of "${proj}"...`); const stats = await stat(proj); let entries: Dirent[]; if (stats.isFile()) { debug_MSBP_PPTMSBP(`"${proj}" is a file. Returning Dirent instance...`); entries = await readdir(path.dirname(proj), { withFileTypes: true }); const dirent: Dirent | undefined = entries.find(v => path.join( // condition required for compatibility. `.path` was deprecated, but `.parentPath` is not available in our node minversion ('path' in v ? v.path as string | undefined : undefined) ?? (v as unknown as Omit<typeof v, 'path'> & { parentPath: string }).parentPath, v.name, ) === proj, ); if (dirent) return dirent; throw new Error( `file "${proj}" not found. It may have been moved or deleted.`, ); } if (!stats.isDirectory()) throw new Error(`"${proj}" is not a file or directory`); debug_MSBP_PPTMSBP(`"${proj}" is a directory. Searching for files ending with ".csproj", ".fsproj", or ".vbproj"...`); entries = await readdir(proj, { withFileTypes: true }); return entries.filter(v => v.isFile() && (v.name.endsWith('.csproj') || v.name.endsWith('.fsproj') || v.name.endsWith('.vbproj')), ); }), ); return directoryEntries.flat(); } /** * Map a {@link Dirent} instance to an {@link MSBuildProject} instance. * @param dirent A {@link Dirent} instance. This instance should be an MSBuild project file. * @returns An instance of {@link MSBuildProject} evaluated with the `Pack` target result, if applicable. Evaluated properties will be those whose names are returned by {@link NPPGetterNames.InstanceGettersRecursive}. */ async function convertDirentToMSBuildProject(dirent: Dirent): Promise<MSBuildProject> { const fullPath = path.join( // condition required for compatibility. `.path` was deprecated, but `.parentPath` is not available in our node minversion ('path' in dirent ? dirent.path as string | undefined : undefined) ?? (dirent as unknown as Omit<typeof dirent, 'path'> & { parentPath: string }).parentPath, dirent.name, ); debug_MSBP_PPTMSBP(`Getting MSBuild targets of "${fullPath}"...`); const projTargets: string[] = await MSBuildProject.GetTargets(fullPath); const evalTargets: string[] = projTargets.includes('Pack') ? ['Pack'] : []; // this might be too long for a command line. What was it on Windows? // 2^15 (32,768) character limit for command lines? const propertiesToEvaluate = NPPGetterNames.InstanceGettersRecursive; const withTargets: string = evalTargets.length === 0 ? '' : ` with target(s) ${evalTargets.join(', ').replace(/, ([^,]+)$/, ', and ${0}')}`; debug_MSBP_PPTMSBP(`Evaluating "${fullPath}" for properties${withTargets}...`); return await MSBuildProject.Evaluate( EvaluationOptions.from({ FullName: fullPath, GetItem: [], GetProperty: propertiesToEvaluate, GetTargetResult: [], Property: {}, Targets: evalTargets, }), ); } } public static fromJSON(json: string): MSBuildProject { const parsed = T_PseudoMSBPInstance.assert(JSON.parse(json)); type.true.assert( Reflect.setPrototypeOf(parsed, this.prototype), ); type.true.assert( Reflect.setPrototypeOf(parsed.Properties, NugetProjectProperties.prototype), ); parsed.Properties = T_NPP.assert(parsed.Properties); return T_MSBuildProject.assert(parsed); } readonly Items: Readonly<Required<MSBuildEvaluationOutput>['Items']>; readonly Properties: Readonly<NugetProjectProperties>; readonly Targets: readonly string[]; /** * Allows appending subsequent target results. */ readonly TargetResults: Required<MSBuildEvaluationOutput>['TargetResults'][]; /** * Creates an instance of MSBuildProject. * @param opts The order-independent arguments for this constructor. * Properties may be added or moved around in this definition without * breaking compatibility. * @param opts.fullPath The full path of the MSBuild project's file. This * should have a '.csproj', '.fsproj', or '.vbproj' file extension. * @param opts.projTargets A list of MSBuild Targets supported by the project. * @param opts.evaluation The output of an MSBuild project evaluation. This * comprises MSBuild Properties, Items, and Target results. */ // eslint-disable-next-line unicorn/name-replacements public constructor(opts: { fullPath: string; projTargets: string[]; evaluation: MSBuildEvaluationOutput; }) { this.Items = opts.evaluation.Items ?? {}; this.Properties = new NugetProjectProperties( opts.fullPath, new CaseInsensitiveMap<string, string>( Object.entries(opts.evaluation.Properties ?? {}), ), ); this.Targets = opts.projTargets; this.TargetResults = opts.evaluation.TargetResults === undefined ? [] : [opts.evaluation.TargetResults]; } } const T_MSBuildProject = type.instanceOf(MSBuildProject); const T_NPP = type.instanceOf(NugetProjectProperties); const T_PseudoMSBPInstance = type({ Items: type({ '[string]': type({ '[string]': 'string', Identity: 'string', FullPath: 'string', RootDir: 'string', Filename: 'string', Extension: 'string', RelativeDir: 'string', Directory: 'string', RecursiveDir: 'string', ModifiedTime: 'string', CreatedTime: 'string', AccessedTime: 'string', DefiningProjectFullPath: 'string', DefiningProjectDirectory: 'string', DefiningProjectName: 'string', DefiningProjectExtension: 'string', 'SubType?': ' string | undefined', 'TargetFrameworkIdentifier?': 'string | undefined', 'TargetPlatformMoniker?': 'string | undefined', 'CopyUpToDateMarker?': 'string | undefined', 'TargetPlatformIdentifier?': 'string | undefined', 'TargetFrameworkVersion?': 'string | undefined', 'ReferenceAssembly?': 'string | undefined', }).array(), }), Properties: type.Record('string', 'string').or(T_NPP), Targets: type.string.array(), TargetResults: msbuildEvaluationOutput.get('TargetResults').exclude('undefined').array(), }); /** * ArkType type definitions for internal usage, but may be re-used elsewhere * @internal */ export const _InternalMSBuildEvaluationTypes: Scope<{ msbuildEvaluationOutput: { Properties?: Record<string, string> | undefined; Items?: Record<string, { [x: string]: string | undefined; Identity: string; FullPath: string; RootDir: string; Filename: string; Extension: string; RelativeDir: string; Directory: string; RecursiveDir: string; ModifiedTime: string; CreatedTime: string; AccessedTime: string; DefiningProjectFullPath: string; DefiningProjectDirectory: string; DefiningProjectName: string; DefiningProjectExtension: string; SubType?: string | undefined; TargetFrameworkIdentifier?: string | undefined; TargetPlatformMoniker?: string | undefined; CopyUpToDateMarker?: string | undefined; TargetPlatformIdentifier?: string | undefined; TargetFrameworkVersion?: string | undefined; ReferenceAssembly?: string | undefined; }[]> | undefined; TargetResults?: Record<string, { Result: 'Success'; Items: { [x: string]: string | undefined; Identity: string; FullPath: string; RootDir: string; Filename: string; Extension: string; RelativeDir: string; Directory: string; RecursiveDir: string; ModifiedTime: string; CreatedTime: string; AccessedTime: string; DefiningProjectFullPath: string; DefiningProjectDirectory: string; DefiningProjectName: string; DefiningProjectExtension: string; SubType?: string | undefined; TargetFrameworkIdentifier?: string | undefined; TargetPlatformMoniker?: string | undefined; CopyUpToDateMarker?: string | undefined; TargetPlatformIdentifier?: string | undefined; TargetFrameworkVersion?: string | undefined; ReferenceAssembly?: string | undefined; }[]; } | { Result: 'Failure'; Items: never[]; }> | undefined; }; }> = type.scope({ msbuildEvaluationOutput, }); /** * Resolve a path if it is not already absolute. * @param _path A filesystem path. * @returns A full path to a filesystem entry. The path is unchecked for whether or not the path (or its parts) exist. */ function makeAbsolute(_path: string) { return path.isAbsolute(_path) ? _path : path.resolve(_path); } /** * Use this in your catch statement or .catch call to return `undefined` when * a "file in use by another process" (i.e. EBUSY/ERROR_SHARING_VIOLATION) error is reported. * @param error Probably an Error object * @returns `undefined` if file in use by another process */ // TODO: BREAKING CHANGE: change return type to `true` to lint implicit `undefined`/`void` returns. export function catchEBUSY(error: unknown): undefined { if (isError(error)) { if ('stderr' in error && typeof error.stderr === 'string') { // Normalize colon-like chars: '\uFF1A'.normalize('NFKC') === ':' === true; const normalizedStderr = error.stderr.normalize('NFKC'); const patternEN = /The process cannot access the file '[^']+' because it is being used by another process\./gm; const hasErrorMessagePattern = patternEN.test(normalizedStderr); const isCS2012 = /^CSC ?:.+CS2012:/gm.test(normalizedStderr); // generic error code; error message must be checked. const isAVLN9999 = /AVLN9999:/gm.test(normalizedStderr) && hasErrorMessagePattern; if (isCS2012 || isAVLN9999 || hasErrorMessagePattern) // eslint-disable-next-line unicorn/no-useless-undefined return undefined; /* retry */ throw error; } /** * some known warnings/errors: * - warning MSB3073: * The command "dotnet tool list kuinox.nupkgdeterministicator" * exited with code 145. * > $ dotnet tool list kuinox.nupkgdeterministicator * > The command could not be loaded, possibly because: * > * You intended to execute a .NET application: * > The application 'tool' does not exist. * > * You intended to execute a .NET SDK command: * > No .NET SDKs were found. * > * > Download a .NET SDK: * > https://aka.ms/dotnet/download * > * > Learn about SDK resolution: * > https://aka.ms/dotnet/sdk-not-found */ throw error; } throw new Error('unknown error', { cause: error }); } /** * * Use this in your catch statement or .catch call to return `undefined` when * MSBuild error CSC2012 (e.g. "file in use by another process") is reported. * @param error Probably an Error object * @returns `undefined` if CSC2012 (file in use by another process) occurs * @deprecated Use {@link catchEBUSY}. */ export function catchCsc2012(error: unknown): undefined { if (isError(error)) { // check for error reported when "file in use by another process" i.e. EBUSY // (UNIX), NTSTATUS.ERROR_SHARING_VIOLATION == 0x20 == 32 (Windows) if ('stderr' in error && typeof error.stderr === 'string' && /^CSC ?:.+CS2012:/gm.test( // '\uFF1A'.normalize('NFKC') === ':' === true; error.stderr.normalize('NFKC'), ) ) { // eslint-disable-next-line unicorn/no-useless-undefined return undefined; /* retry */ } /** * some known warnings/errors: * - warning MSB3073: * The command "dotnet tool list kuinox.nupkgdeterministicator" * exited with code 145. * > $ dotnet tool list kuinox.nupkgdeterministicator * > The command could not be loaded, possibly because: * > * You intended to execute a .NET application: * > The application 'tool' does not exist. * > * You intended to execute a .NET SDK command: * > No .NET SDKs were found. * > * > Download a .NET SDK: * > https://aka.ms/dotnet/download * > * > Learn about SDK resolution: * > https://aka.ms/dotnet/sdk-not-found */ throw error; } throw new Error('unknown error', { cause: error }); } /** * * @returns A Promise of execAsync's output object. * @param root0 structured parameters object * @param root0.commandLine The command line to try and retry * @param root0.taskVerb Used in debug messages e.g. ``` * const debug_MSBP = debug.extend('MSBuildProject'); * debug_MSBP.enabled = debug.enabled; * const output = await loopTryDotnetCommand({ customDebugger: debug_MSBP, ... }); * ``` * @param root0.customDebugger e.g. `debug.extend('Evaluate)` * @param root0.projectName The filename (sans extension) or AssemblyName of the project e.g. `path.basename(fullPath, path.extname(fullPath))` * @param root0.timeoutMilliseconds The maximum time spent (re)trying the command. * Warning! {@link totalMilliseconds} may be significantly greater than {@link timeoutMilliseconds}! * e.g. * `325_000 <=300_000` (5m25s vs 5m) * `378_000 <=360_000` (6m18s vs 6m), * `630_000 <=600_000` (10m30s vs 10m) * `1225_000 <=1200_000` (20m25s vs 20m) * `2415_000 <=2400_000` (40m15s vs 40m) * `2556_000 <=2485_000` (42m36s vs 41m25s; 71 seconds over) * @throws {Error} when retry limit is reached or an unhandled exception occurs. * File-in-use errors are _supposed_ to be ignored and retried. */ export async function loopTryDotnetCommand({ commandLine, customDebugger, projectName, taskVerb, timeoutMilliseconds: maximumTime }: { commandLine: string; customDebugger: typeof debug; projectName: string; taskVerb?: string | undefined; timeoutMilliseconds: number; }): Promise<Awaited<ReturnType<typeof execAsync>>> { let output: Awaited<ReturnType<typeof execAsync>> | undefined; let totalMilliseconds = 0; let delay = 0; taskVerb ??= '<undefined>'; customDebugger(`Beginning try/retry loop to ${taskVerb} "${projectName}"...`); while (output === undefined) { try { await setTimeout(delay, undefined); output = await execAsync(commandLine, true); } catch (error: unknown) { if (totalMilliseconds > maximumTime /* milliseconds */) { throw new Error( `Unable to ${taskVerb} "${projectName}": Retry limit hit. ~${(totalMilliseconds / 1000).toString()} seconds spent retrying.`, { cause: error }, ); }; catchEBUSY(error); // incremental back-off; add new delay to total totalMilliseconds += delay += 1000; customDebugger(`A file needed by "${projectName}" is locked by another process. Retrying after ${(delay / 1000).toString()} seconds...`); } }; return output; }