UNPKG

tstyche

Version:

Everything You Need for Type Testing.

1,427 lines (1,400 loc) 247 kB
import { writeFileSync, rmSync, existsSync, watch } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import os from 'node:os'; import process from 'node:process'; class EventEmitter { static instanceCount = 0; static #handlers = new Map(); static #reporters = new Map(); #scope; constructor() { this.#scope = EventEmitter.instanceCount++; EventEmitter.#handlers.set(this.#scope, new Set()); EventEmitter.#reporters.set(this.#scope, new Set()); } addHandler(handler) { EventEmitter.#handlers.get(this.#scope)?.add(handler); } addReporter(reporter) { EventEmitter.#reporters.get(this.#scope)?.add(reporter); } static dispatch(event) { function forEachHandler(handlers, event) { for (const handler of handlers) { handler.on(event); } } for (const handlers of EventEmitter.#handlers.values()) { forEachHandler(handlers, event); } for (const handlers of EventEmitter.#reporters.values()) { forEachHandler(handlers, event); } } removeHandler(handler) { EventEmitter.#handlers.get(this.#scope)?.delete(handler); } removeReporter(reporter) { EventEmitter.#reporters.get(this.#scope)?.delete(reporter); } removeHandlers() { EventEmitter.#handlers.get(this.#scope)?.clear(); } removeReporters() { EventEmitter.#reporters.get(this.#scope)?.clear(); } } class JsonNode { origin; text; constructor(text, origin) { this.origin = origin; this.text = text; } getValue(options) { if (this.text != null) { if (/^['"]/.test(this.text)) { return this.text.slice(1, -1).replaceAll("\\", ""); } if (options?.expectsIdentifier) { return this.text; } if (this.text === "true") { return true; } if (this.text === "false") { return false; } if (/^\d/.test(this.text)) { return Number.parseFloat(this.text); } } return; } } class SourceService { static #files = new Map(); static delete(filePath) { SourceService.#files.delete(filePath); } static get(source) { const file = SourceService.#files.get(source.fileName); if (file != null) { return file; } return source; } static set(source) { SourceService.#files.set(source.fileName, source); } } class DiagnosticOrigin { assertionNode; end; sourceFile; start; constructor(start, end, sourceFile, assertionNode) { this.start = start; this.end = end; this.sourceFile = SourceService.get(sourceFile); this.assertionNode = assertionNode; } static fromAssertion(assertionNode) { const node = assertionNode.matcherNameNode.name; return new DiagnosticOrigin(node.getStart(), node.getEnd(), node.getSourceFile(), assertionNode); } static fromNode(node, assertionNode) { return new DiagnosticOrigin(node.getStart(), node.getEnd(), node.getSourceFile(), assertionNode); } } function diagnosticBelongsToNode(diagnostic, node) { return diagnostic.start != null && diagnostic.start >= node.pos && diagnostic.start <= node.end; } function diagnosticMessageChainToText(chain) { const result = [chain.messageText]; if (chain.next != null) { for (const nextChain of chain.next) { result.push(...diagnosticMessageChainToText(nextChain)); } } return result; } function getDiagnosticMessageText(diagnostic) { return typeof diagnostic.messageText === "string" ? diagnostic.messageText : diagnosticMessageChainToText(diagnostic.messageText); } function getTextSpanEnd(span) { return span.start + span.length; } function isDiagnosticWithLocation(diagnostic) { return diagnostic.file != null && diagnostic.start != null && diagnostic.length != null; } class Diagnostic { category; code; origin; related; text; constructor(text, category, origin) { this.text = text; this.category = category; this.origin = origin; } add(options) { if (options.code != null) { this.code = options.code; } if (options.related != null) { this.related = options.related; } return this; } static error(text, origin) { return new Diagnostic(text, "error", origin); } extendWith(text, origin) { return new Diagnostic([this.text, text].flat(), this.category, origin ?? this.origin); } static fromDiagnostics(diagnostics) { return diagnostics.map((diagnostic) => { const code = `ts(${diagnostic.code})`; let origin; if (isDiagnosticWithLocation(diagnostic)) { origin = new DiagnosticOrigin(diagnostic.start, getTextSpanEnd(diagnostic), diagnostic.file); } let related; if (diagnostic.relatedInformation != null) { related = Diagnostic.fromDiagnostics(diagnostic.relatedInformation); } const text = getDiagnosticMessageText(diagnostic); return new Diagnostic(text, "error", origin).add({ code, related }); }); } static warning(text, origin) { return new Diagnostic(text, "warning", origin); } } var DiagnosticCategory; (function (DiagnosticCategory) { DiagnosticCategory["Error"] = "error"; DiagnosticCategory["Warning"] = "warning"; })(DiagnosticCategory || (DiagnosticCategory = {})); class JsonScanner { #end; #position; #previousPosition; #sourceFile; constructor(sourceFile, options) { this.#end = options?.end ?? sourceFile.text.length; this.#position = options?.start ?? 0; this.#previousPosition = options?.start ?? 0; this.#sourceFile = sourceFile; } #getOrigin() { return new DiagnosticOrigin(this.#previousPosition, this.#position, this.#sourceFile); } isRead() { return !(this.#position < this.#end); } #peekCharacter() { return this.#sourceFile.text.charAt(this.#position); } #peekNextCharacter() { return this.#sourceFile.text.charAt(this.#position + 1); } peekToken(token) { this.#skipTrivia(); return this.#peekCharacter() === token; } read() { this.#skipTrivia(); this.#previousPosition = this.#position; if (/[\s,:\]}]/.test(this.#peekCharacter())) { return new JsonNode(undefined, this.#getOrigin()); } let text = ""; let closingTokenText = ""; if (/[[{'"]/.test(this.#peekCharacter())) { text += this.#readCharacter(); switch (text) { case "[": closingTokenText = "]"; break; case "{": closingTokenText = "}"; break; default: closingTokenText = text; } } while (!this.isRead()) { text += this.#readCharacter(); if ((text.at(-2) !== "\\" && text.at(-1) === closingTokenText) || (!closingTokenText && /[\s,:\]}]/.test(this.#peekCharacter()))) { break; } } return new JsonNode(text, this.#getOrigin()); } #readCharacter() { return this.#sourceFile.text.charAt(this.#position++); } readToken(token) { this.#skipTrivia(); this.#previousPosition = this.#position; const character = this.#peekCharacter(); if (typeof token === "string" ? token === character : token.test(character)) { this.#position++; return new JsonNode(character, this.#getOrigin()); } return new JsonNode(undefined, this.#getOrigin()); } #skipTrivia() { while (!this.isRead()) { if (/\s/.test(this.#peekCharacter())) { this.#position++; continue; } if (this.#peekCharacter() === "/") { if (this.#peekNextCharacter() === "/") { this.#position += 2; while (!this.isRead()) { if (this.#readCharacter() === "\n") { break; } } continue; } if (this.#peekNextCharacter() === "*") { this.#position += 2; while (!this.isRead()) { if (this.#peekCharacter() === "*" && this.#peekNextCharacter() === "/") { this.#position += 2; break; } this.#position++; } continue; } } break; } this.#previousPosition = this.#position; } } class JsonSourceFile { fileName; #lineMap; text; constructor(fileName, text) { this.fileName = fileName; this.text = text; this.#lineMap = this.#createLineMap(); } #createLineMap() { const result = [0]; let position = 0; while (position < this.text.length) { if (this.text.charAt(position - 1) === "\r") { position++; } if (this.text.charAt(position - 1) === "\n") { result.push(position); } position++; } result.push(position); return result; } getLineStarts() { return this.#lineMap; } getLineAndCharacterOfPosition(position) { const line = this.#lineMap.findLastIndex((line) => line <= position); const character = position - this.#lineMap[line]; return { line, character }; } } class Path { static normalizeSlashes; static { if (path.sep === "/") { Path.normalizeSlashes = (filePath) => filePath; } else { Path.normalizeSlashes = (filePath) => filePath.replace(/\\/g, "/"); } } static dirname(filePath) { return Path.normalizeSlashes(path.dirname(filePath)); } static join(...filePaths) { return Path.normalizeSlashes(path.join(...filePaths)); } static relative(from, to) { const relativePath = Path.normalizeSlashes(path.relative(from, to)); if (/^\.\.?\//.test(relativePath)) { return relativePath; } return `./${relativePath}`; } static resolve(...filePaths) { return Path.normalizeSlashes(path.resolve(...filePaths)); } } class ConfigDiagnosticText { static expected(element) { return `Expected ${element}.`; } static expectsListItemType(optionName, optionBrand) { return `Item of the '${optionName}' list must be of type ${optionBrand}.`; } static expectsValue(optionName) { return `Option '${optionName}' expects a value.`; } static fileDoesNotExist(filePath) { return `The specified path '${filePath}' does not exist.`; } static fileMatchPatternCannotStartWith(optionName, segment) { return [ `A '${optionName}' pattern cannot start with '${segment}'.`, "The files are only collected within the root directory.", ]; } static inspectSupportedVersions() { return "Use the '--list' command line option to inspect the list of supported versions."; } static moduleWasNotFound(specifier) { return `The specified module '${specifier}' was not found.`; } static optionValueMustBe(optionName, optionBrand) { return `Value for the '${optionName}' option must be a ${optionBrand}.`; } static rangeIsNotValid(value) { return `The specified range '${value}' is not valid.`; } static rangeDoesNotMatchSupported(value) { return `The specified range '${value}' does not match any supported TypeScript versions.`; } static rangeUsage() { return [ "A range must be specified using an operator and a minor version: '>=5.8'.", "To set an upper bound, use the intersection of two ranges: '>=5.4 <5.6'.", "Use the '||' operator to join ranges into a union: '>=5.4 <5.6 || 5.6.2 || >=5.8'.", ]; } static seen(element) { return `The ${element} was seen here.`; } static unexpected(element) { return `Unexpected ${element}.`; } static unknownOption(optionName) { return `Unknown option '${optionName}'.`; } static versionIsNotSupported(value) { return `The TypeScript version '${value}' is not supported.`; } static watchCannotBeEnabled() { return "Watch mode cannot be enabled in continuous integration environment."; } } class Environment { static resolve() { return { isCi: Environment.#resolveIsCi(), noColor: Environment.#resolveNoColor(), noInteractive: Environment.#resolveNoInteractive(), npmRegistry: Environment.#resolveNpmRegistry(), storePath: Environment.#resolveStorePath(), timeout: Environment.#resolveTimeout(), typescriptModule: Environment.#resolveTypeScriptModule(), }; } static #resolveIsCi() { if (process.env["CI"] != null) { return process.env["CI"] !== ""; } return false; } static #resolveNoColor() { if (process.env["TSTYCHE_NO_COLOR"] != null) { return process.env["TSTYCHE_NO_COLOR"] !== ""; } if (process.env["NO_COLOR"] != null) { return process.env["NO_COLOR"] !== ""; } return false; } static #resolveNoInteractive() { if (process.env["TSTYCHE_NO_INTERACTIVE"] != null) { return process.env["TSTYCHE_NO_INTERACTIVE"] !== ""; } return !process.stdout.isTTY; } static #resolveNpmRegistry() { if (process.env["TSTYCHE_NPM_REGISTRY"] != null) { return process.env["TSTYCHE_NPM_REGISTRY"]; } return "https://registry.npmjs.org"; } static #resolveStorePath() { if (process.env["TSTYCHE_STORE_PATH"] != null) { return Path.resolve(process.env["TSTYCHE_STORE_PATH"]); } if (process.platform === "darwin") { return Path.resolve(os.homedir(), "Library", "TSTyche"); } if (process.env["LocalAppData"] != null) { return Path.resolve(process.env["LocalAppData"], "TSTyche"); } if (process.env["XDG_DATA_HOME"] != null) { return Path.resolve(process.env["XDG_DATA_HOME"], "TSTyche"); } return Path.resolve(os.homedir(), ".local", "share", "TSTyche"); } static #resolveTimeout() { if (process.env["TSTYCHE_TIMEOUT"] != null) { return Number.parseFloat(process.env["TSTYCHE_TIMEOUT"]); } return 30; } static #resolveTypeScriptModule() { let specifier = "typescript"; if (process.env["TSTYCHE_TYPESCRIPT_MODULE"] != null) { specifier = process.env["TSTYCHE_TYPESCRIPT_MODULE"]; } let resolvedModule; try { resolvedModule = import.meta.resolve(specifier); } catch { } return resolvedModule; } } const environmentOptions = Environment.resolve(); class Version { static isGreaterThan(source, target) { return !(source === target) && Version.#satisfies(source, target); } static isIncluded(source, range) { return range.some((target) => source.startsWith(target)); } static isSatisfiedWith(source, target) { return source === target || Version.#satisfies(source, target); } static #satisfies(source, target) { const sourceElements = source.split(/\.|-/); const targetElements = target.split(/\.|-/); function compare(index = 0) { const sourceElement = sourceElements[index]; const targetElement = targetElements[index]; if (sourceElement > targetElement) { return true; } if (sourceElement < targetElement) { return false; } if (index === sourceElements.length - 1 || index === targetElements.length - 1) { return true; } return compare(index + 1); } return compare(); } } class StoreDiagnosticText { static cannotAddTypeScriptPackage(tag) { return `Cannot add the 'typescript' package for the '${tag}' tag.`; } static failedToFetchMetadata(registry) { return `Failed to fetch metadata of the 'typescript' package from '${registry}'.`; } static failedToFetchPackage(version) { return `Failed to fetch the 'typescript@${version}' package.`; } static failedToUpdateMetadata(registry) { return `Failed to update metadata of the 'typescript' package from '${registry}'.`; } static maybeNetworkConnectionIssue() { return "Might be there is an issue with the registry or the network connection."; } static maybeOutdatedResolution(tag) { return `The resolution of the '${tag}' tag may be outdated.`; } static requestFailedWithStatusCode(code) { return `The request failed with status code ${code}.`; } static requestTimeoutWasExceeded(timeout) { return `The request timeout of ${timeout / 1000}s was exceeded.`; } static lockWaitTimeoutWasExceeded(timeout) { return `Lock wait timeout of ${timeout / 1000}s was exceeded.`; } } class Fetcher { #onDiagnostics; #timeout; constructor(onDiagnostics, timeout) { this.#onDiagnostics = onDiagnostics; this.#timeout = timeout; } async get(request, diagnostic, options) { try { const response = await fetch(request, { signal: AbortSignal.timeout(this.#timeout) }); if (!response.ok) { !options?.suppressErrors && this.#onDiagnostics(diagnostic.extendWith(StoreDiagnosticText.requestFailedWithStatusCode(response.status))); return; } return response; } catch (error) { if (error instanceof Error && error.name === "TimeoutError") { !options?.suppressErrors && this.#onDiagnostics(diagnostic.extendWith(StoreDiagnosticText.requestTimeoutWasExceeded(this.#timeout))); } else { !options?.suppressErrors && this.#onDiagnostics(diagnostic.extendWith(StoreDiagnosticText.maybeNetworkConnectionIssue())); } } return; } } class Lock { #lockFilePath; constructor(lockFilePath) { this.#lockFilePath = lockFilePath; writeFileSync(this.#lockFilePath, ""); process.on("exit", () => { this.release(); }); } release() { rmSync(this.#lockFilePath, { force: true }); } } class LockService { #onDiagnostics; #timeout; constructor(onDiagnostics, timeout) { this.#onDiagnostics = onDiagnostics; this.#timeout = timeout; } #getLockFilePath(targetPath) { return `${targetPath}__lock__`; } getLock(targetPath) { const lockFilePath = this.#getLockFilePath(targetPath); return new Lock(lockFilePath); } async isLocked(targetPath, diagnostic) { const lockFilePath = this.#getLockFilePath(targetPath); let isLocked = existsSync(lockFilePath); if (!isLocked) { return isLocked; } const waitStartTime = Date.now(); while (isLocked) { if (Date.now() - waitStartTime > this.#timeout) { this.#onDiagnostics(diagnostic.extendWith(StoreDiagnosticText.lockWaitTimeoutWasExceeded(this.#timeout))); break; } await this.#sleep(1000); isLocked = existsSync(lockFilePath); } return isLocked; } #sleep(delay) { return new Promise((resolve) => setTimeout(resolve, delay)); } } class Manifest { static #version = "3"; $version; lastUpdated; minorVersions; npmRegistry; packages; resolutions; versions; constructor(data) { this.$version = data.$version ?? Manifest.#version; this.lastUpdated = data.lastUpdated ?? Date.now(); this.minorVersions = data.minorVersions; this.npmRegistry = data.npmRegistry; this.packages = data.packages; this.resolutions = data.resolutions; this.versions = data.versions; } isOutdated(options) { if (Date.now() - this.lastUpdated > 2 * 60 * 60 * 1000 + (options?.ageTolerance ?? 0) * 1000) { return true; } return false; } static parse(text) { let manifestData; try { manifestData = JSON.parse(text); } catch { } if (manifestData != null && manifestData.$version === Manifest.#version) { return new Manifest(manifestData); } return; } resolve(tag) { if (tag === "*") { return this.resolutions["latest"]; } if (this.versions.includes(tag)) { return tag; } return this.resolutions[tag]; } stringify() { const manifestData = { $version: this.$version, lastUpdated: this.lastUpdated, minorVersions: this.minorVersions, npmRegistry: this.npmRegistry, packages: this.packages, resolutions: this.resolutions, versions: this.versions, }; return JSON.stringify(manifestData); } } class ManifestService { #fetcher; #manifestFilePath; #npmRegistry; #storePath; constructor(storePath, npmRegistry, fetcher) { this.#storePath = storePath; this.#npmRegistry = npmRegistry; this.#fetcher = fetcher; this.#manifestFilePath = Path.join(storePath, "store-manifest.json"); } async #create() { const manifest = await this.#load(); if (manifest != null) { await this.#persist(manifest); } return manifest; } async #load(options) { const diagnostic = Diagnostic.error(StoreDiagnosticText.failedToFetchMetadata(this.#npmRegistry)); const request = new Request(new URL("typescript", this.#npmRegistry), { headers: { ["Accept"]: "application/vnd.npm.install-v1+json;q=1.0, application/json;q=0.8, */*", }, }); const response = await this.#fetcher.get(request, diagnostic, { suppressErrors: options?.suppressErrors }); if (!response) { return; } const resolutions = {}; const packages = {}; const versions = []; const packageMetadata = (await response.json()); for (const [tag, meta] of Object.entries(packageMetadata.versions)) { if (!tag.includes("-") && Version.isSatisfiedWith(tag, "5.4") && !Version.isSatisfiedWith(tag, "7.0")) { versions.push(tag); packages[tag] = { integrity: meta.dist.integrity, tarball: meta.dist.tarball }; } } const minorVersions = [...new Set(versions.map((version) => version.slice(0, -2)))]; for (const tag of minorVersions) { const resolvedVersion = versions.findLast((version) => version.startsWith(tag)); if (resolvedVersion != null) { resolutions[tag] = resolvedVersion; } } for (const tag of ["beta", "latest", "next", "rc"]) { const version = packageMetadata["dist-tags"][tag]; if (version != null) { resolutions[tag] = version; const meta = packageMetadata.versions[version]; if (meta != null) { packages[version] = { integrity: meta.dist.integrity, tarball: meta.dist.tarball }; } } } resolutions["latest"] = versions.findLast((version) => version.startsWith("6")); return new Manifest({ minorVersions, npmRegistry: this.#npmRegistry, packages, resolutions, versions }); } async open(options) { if (!existsSync(this.#manifestFilePath)) { return this.#create(); } const manifestText = await fs.readFile(this.#manifestFilePath, { encoding: "utf8" }); const manifest = Manifest.parse(manifestText); if (!manifest || manifest.npmRegistry !== this.#npmRegistry) { await this.prune(); return this.#create(); } if (manifest.isOutdated() || options?.refresh) { const freshManifest = await this.#load({ suppressErrors: !options?.refresh }); if (freshManifest != null) { await this.#persist(freshManifest); return freshManifest; } } return manifest; } async #persist(manifest) { await fs.mkdir(this.#storePath, { recursive: true }); await fs.writeFile(this.#manifestFilePath, manifest.stringify()); } async prune() { await fs.rm(this.#storePath, { force: true, recursive: true }); } } class TarReader { #leftover = new Uint8Array(0); #reader; #textDecoder = new TextDecoder(); constructor(stream) { this.#reader = stream.getReader(); } async *extract() { while (true) { const header = await this.#read(512); if (this.#isEndOfArchive(header)) { break; } const name = this.#textDecoder.decode(header.subarray(0, 100)).replace(/\0.*$/, ""); const sizeOctal = this.#textDecoder.decode(header.subarray(124, 136)).replace(/\0.*$/, "").trim(); const size = Number.parseInt(sizeOctal, 8); const content = await this.#read(size); yield { name, content }; if (size % 512 !== 0) { const toSkip = 512 - (size % 512); await this.#read(toSkip); } } } #isEndOfArchive(entry) { return entry.every((byte) => byte === 0); } async #read(n) { const result = new Uint8Array(n); let filled = 0; if (this.#leftover.length > 0) { const toCopy = Math.min(this.#leftover.length, n); result.set(this.#leftover.subarray(0, toCopy), filled); filled += toCopy; this.#leftover = this.#leftover.subarray(toCopy); if (filled === n) { return result; } } while (filled < n) { const { value, done } = await this.#reader.read(); if (done) { break; } const toCopy = Math.min(value.length, n - filled); result.set(value.subarray(0, toCopy), filled); filled += toCopy; if (toCopy < value.length) { this.#leftover = value.subarray(toCopy); break; } } return result.subarray(0, filled); } } class PackageService { #fetcher; #lockService; #storePath; constructor(storePath, fetcher, lockService) { this.#storePath = storePath; this.#fetcher = fetcher; this.#lockService = lockService; } async ensure(packageVersion, manifest) { const packagePath = Path.join(this.#storePath, `typescript@${packageVersion}`); const diagnostic = Diagnostic.error(StoreDiagnosticText.failedToFetchPackage(packageVersion)); if (await this.#lockService.isLocked(packagePath, diagnostic)) { return; } if (existsSync(packagePath)) { return packagePath; } EventEmitter.dispatch(["store:adds", { packagePath, packageVersion }]); const resource = manifest.packages[packageVersion]; const lock = this.#lockService.getLock(packagePath); try { const request = new Request(resource.tarball, { integrity: resource.integrity }); const response = await this.#fetcher.get(request, diagnostic); if (!response?.body) { return; } const targetPath = await fs.mkdtemp(`${packagePath}-`); const stream = response.body.pipeThrough(new DecompressionStream("gzip")); const tarReader = new TarReader(stream); for await (const file of tarReader.extract()) { const filePath = Path.join(targetPath, file.name.replace(/^package\//, "")); const directoryPath = Path.dirname(filePath); await fs.mkdir(directoryPath, { recursive: true }); await fs.writeFile(filePath, file.content); } await fs.rename(targetPath, packagePath); return packagePath; } finally { lock.release(); } } } class Store { static #fetcher; static #lockService; static manifest; static #manifestService; static #packageService; static #npmRegistry = environmentOptions.npmRegistry; static #storePath = environmentOptions.storePath; static #supportedTags; static #timeout = environmentOptions.timeout * 1000; static { Store.#fetcher = new Fetcher(Store.#onDiagnostics, Store.#timeout); Store.#lockService = new LockService(Store.#onDiagnostics, Store.#timeout); Store.#packageService = new PackageService(Store.#storePath, Store.#fetcher, Store.#lockService); Store.#manifestService = new ManifestService(Store.#storePath, Store.#npmRegistry, Store.#fetcher); } static async #ensure(tag) { await Store.open(); const version = Store.manifest?.resolve(tag); if (!version) { Store.#onDiagnostics(Diagnostic.error(StoreDiagnosticText.cannotAddTypeScriptPackage(tag))); return; } return await Store.#packageService.ensure(version, Store.manifest); } static async fetch(tag) { if (tag === "*" && environmentOptions.typescriptModule != null) { return; } await Store.#ensure(tag); } static async load(tag) { let resolvedModule; if (tag === "*" && environmentOptions.typescriptModule != null) { resolvedModule = environmentOptions.typescriptModule; } else { const packagePath = await Store.#ensure(tag); if (packagePath != null) { resolvedModule = pathToFileURL(`${packagePath}/lib/typescript.js`).toString(); } } if (resolvedModule != null) { return (await import(resolvedModule)).default; } return; } static #onDiagnostics(diagnostic) { EventEmitter.dispatch(["store:error", { diagnostics: [diagnostic] }]); } static async open() { if (Store.manifest != null) { return; } Store.manifest = await Store.#manifestService.open(); if (Store.manifest != null) { Store.#supportedTags = [...Object.keys(Store.manifest.resolutions), ...Store.manifest.versions]; } } static async prune() { await Store.#manifestService.prune(); } static async update() { await Store.#manifestService.open({ refresh: true }); } static async validateTag(tag) { if (tag === "*") { return true; } await Store.open(); if (Store.manifest?.isOutdated({ ageTolerance: 60 }) && (!/^\d/.test(tag) || (Store.manifest.resolutions["latest"] != null && Version.isGreaterThan(tag, Store.manifest.resolutions["latest"])))) { Store.#onDiagnostics(Diagnostic.warning([ StoreDiagnosticText.failedToUpdateMetadata(Store.#npmRegistry), StoreDiagnosticText.maybeOutdatedResolution(tag), ])); } return Store.#supportedTags?.includes(tag); } } class Target { static #rangeRegex = /^[<>]=?\d\.\d( [<>]=?\d\.\d)?$/; static async expand(range, onDiagnostics, origin) { if (Target.isRange(range)) { await Store.open(); if (Store.manifest != null) { let versions = [...Store.manifest.minorVersions]; for (const comparator of range.split(" ")) { versions = Target.#filter(comparator.trim(), versions); if (versions.length === 0) { const text = [ ConfigDiagnosticText.rangeDoesNotMatchSupported(range), ConfigDiagnosticText.inspectSupportedVersions(), ]; onDiagnostics(Diagnostic.error(text, origin)); } } return versions; } } return [range]; } static #filter(comparator, versions) { const targetVersion = comparator.replace(/^[<>]=?/, ""); switch (comparator.charAt(0)) { case ">": return versions.filter((sourceVersion) => comparator.charAt(1) === "=" ? Version.isSatisfiedWith(sourceVersion, targetVersion) : Version.isGreaterThan(sourceVersion, targetVersion)); case "<": return versions.filter((sourceVersion) => comparator.charAt(1) === "=" ? Version.isSatisfiedWith(targetVersion, sourceVersion) : Version.isGreaterThan(targetVersion, sourceVersion)); } return []; } static isRange(query) { return Target.#rangeRegex.test(query); } static split(range) { return range.split(/ *\|\| */); } } class Options { static #definitions = [ { brand: "string", description: "The Url to the config file validation schema.", group: 4, name: "$schema", }, { brand: "boolean", description: "Check declaration files for type errors.", group: 4, name: "checkDeclarationFiles", }, { brand: "boolean", description: "Check errors suppressed by '@ts-expect-error' directives.", group: 4, name: "checkSuppressedErrors", }, { brand: "string", description: "The path to a TSTyche configuration file.", group: 2, name: "config", }, { brand: "boolean", description: "Stop running tests after the first failed assertion.", group: 4 | 2, name: "failFast", }, { brand: "true", description: "Fetch the specified versions of the 'typescript' package and exit.", group: 2, name: "fetch", }, { brand: "list", description: "The list of glob patterns matching the fixture files.", group: 4, items: { brand: "string", name: "fixtureFileMatch", }, name: "fixtureFileMatch", }, { brand: "true", description: "Print the list of command line options with brief descriptions and exit.", group: 2, name: "help", }, { brand: "true", description: "Print the list of supported versions of the 'typescript' package and exit.", group: 2, name: "list", }, { brand: "true", description: "Print the list of selected test files and exit.", group: 2, name: "listFiles", }, { brand: "string", description: "Only run tests with a matching name.", group: 2, name: "only", }, { brand: "true", description: "Remove all fetched versions of the 'typescript' package and exit.", group: 2, name: "prune", }, { brand: "boolean", description: "Silence all test runner output except errors and warnings.", group: 2 | 4, name: "quiet", }, { brand: "boolean", description: "Reject the 'any' type passed as an argument to the 'expect()' function or a matcher.", group: 4, name: "rejectAnyType", }, { brand: "boolean", description: "Reject the 'never' type passed as an argument to the 'expect()' function or a matcher.", group: 4, name: "rejectNeverType", }, { brand: "list", description: "The list of reporters to use.", group: 2 | 4, items: { brand: "string", name: "reporters", }, name: "reporters", }, { brand: "string", description: "The path to the root directory of a test project.", group: 2, name: "root", }, { brand: "true", description: "Print the resolved configuration and exit.", group: 2, name: "showConfig", }, { brand: "string", description: "Skip tests with a matching name.", group: 2, name: "skip", }, { brand: "range", description: "The TypeScript version or range of versions to test against.", group: 2 | 4 | 8, name: "target", }, { brand: "list", description: "The list of glob patterns matching the test files.", group: 4, items: { brand: "string", name: "testFileMatch", }, name: "testFileMatch", }, { brand: "string", description: "The TSConfig to load.", group: 2 | 4, name: "tsconfig", }, { brand: "true", description: "Fetch the 'typescript' package metadata from the registry and exit.", group: 2, name: "update", }, { brand: "boolean", description: "Enable detailed logging.", group: 2 | 4, name: "verbose", }, { brand: "true", description: "Print the version number and exit.", group: 2, name: "version", }, { brand: "true", description: "Watch for changes and rerun related test files.", group: 2, name: "watch", }, ]; static for(optionGroup) { const definitionMap = new Map(); for (const definition of Options.#definitions) { if (definition.group & optionGroup) { definitionMap.set(definition.name, definition); } } return definitionMap; } static #getCanonicalOptionName(optionName) { return optionName.startsWith("--") ? optionName.slice(2) : optionName; } static #isBuiltinReporter(optionValue) { return ["dot", "list", "summary"].includes(optionValue); } static #isLookupStrategy(optionValue) { return ["findup", "baseline"].includes(optionValue); } static isJsonString(text) { return text.startsWith("{"); } static resolve(optionName, optionValue, basePath = ".") { const canonicalOptionName = Options.#getCanonicalOptionName(optionName); switch (canonicalOptionName) { case "config": case "root": case "tsconfig": if (canonicalOptionName === "tsconfig" && (Options.#isLookupStrategy(optionValue) || Options.isJsonString(optionValue))) { break; } if (optionValue.startsWith("file:")) { optionValue = fileURLToPath(optionValue); } optionValue = Path.resolve(basePath, optionValue); break; case "reporters": if (Options.#isBuiltinReporter(optionValue)) { break; } try { if (optionValue.startsWith(".")) { optionValue = pathToFileURL(Path.relative(".", Path.resolve(basePath, optionValue))).toString(); } else { optionValue = import.meta.resolve(optionValue); } } catch { } break; } return optionValue; } static async validate(optionName, optionValue, onDiagnostics, origin) { const canonicalOptionName = Options.#getCanonicalOptionName(optionName); switch (canonicalOptionName) { case "config": case "root": case "tsconfig": if (canonicalOptionName === "tsconfig" && (Options.#isLookupStrategy(optionValue) || Options.isJsonString(optionValue))) { break; } if (existsSync(optionValue)) { break; } onDiagnostics(Diagnostic.error(ConfigDiagnosticText.fileDoesNotExist(optionValue), origin)); break; case "reporters": if (Options.#isBuiltinReporter(optionValue)) { break; } if (optionValue.startsWith("file:") && existsSync(new URL(optionValue))) { break; } onDiagnostics(Diagnostic.error(ConfigDiagnosticText.moduleWasNotFound(optionValue), origin)); break; case "target": { if (/[<>=]/.test(optionValue)) { if (!Target.isRange(optionValue)) { const text = [ConfigDiagnosticText.rangeIsNotValid(optionValue), ...ConfigDiagnosticText.rangeUsage()]; onDiagnostics(Diagnostic.error(text, origin)); } break; } if ((await Store.validateTag(optionValue)) === false) { const text = [ ConfigDiagnosticText.versionIsNotSupported(optionValue), ConfigDiagnosticText.inspectSupportedVersions(), ]; onDiagnostics(Diagnostic.error(text, origin)); } break; } case "fixtureFileMatch": case "testFileMatch": for (const segment of ["/", "../"]) { if (optionValue.startsWith(segment)) { onDiagnostics(Diagnostic.error(ConfigDiagnosticText.fileMatchPatternCannotStartWith(canonicalOptionName, segment), origin)); } } break; case "watch": if (environmentOptions.isCi) { onDiagnostics(Diagnostic.error(ConfigDiagnosticText.watchCannotBeEnabled(), origin)); } break; } } } class CommandParser { #commandLineOptions; #onDiagnostics; #options; #pathMatch; constructor(commandLine, pathMatch, onDiagnostics) { this.#commandLineOptions = commandLine; this.#pathMatch = pathMatch; this.#onDiagnostics = onDiagnostics; this.#options = Options.for(2); } #onExpectsValue(optionName, optionBrand) { const text = [ ConfigDiagnosticText.expectsValue(optionName), ConfigDiagnosticText.optionValueMustBe(optionName, optionBrand), ]; this.#onDiagnostics(Diagnostic.error(text)); } async parse(commandLineArgs) { let index = 0; let arg = commandLineArgs[index]; while (arg != null) { index++; if (arg.startsWith("--")) { const optionDefinition = this.#options.get(arg.slice(2)); if (optionDefinition) { index = await this.#parseOptionValue(commandLineArgs, index, arg, optionDefinition); } else { this.#onDiagnostics(Diagnostic.error(ConfigDiagnosticText.unknownOption(arg))); } } else if (arg.startsWith("-")) { this.#onDiagnostics(Diagnostic.error(ConfigDiagnosticText.unknownOption(arg))); } else { this.#pathMatch.push(Path.normalizeSlashes(arg)); } arg = commandLineArgs[index]; } } async #parseOptionValue(commandLineArgs, index, optionName, optionDefinition) { let optionValue = this.#resolveOptionValue(commandLineArgs[index]); switch (optionDefinition.brand) { case "true": await Options.validate(optionName, optionValue, this.#onDiagnostics); this.#commandLineOptions[optionDefinition.name] = true; break; case "boolean": await Options.validate(optionName, optionValue, this.#onDiagnostics); this.#commandLineOptions[optionDefinition.name] = optionValue !== "false"; if (optionValue === "false" || optionValue === "true") { index++; } break; case "list": if (optionValue !== "") { const optionValues = optionValue .split(",") .map((value) => value.trim()) .filter((value) => value !== "") .map((value) => Options.resolve(optionName, value)); for (const optionValue of optionValues) { await Options.validate(optionName, optionValue, this.#onDiagnostics); } this.#commandLineOptions[optionDefinition.name] = optionValues; index++; break; } this.#onExpectsValue(optionName, optionDefinition.brand); break; case "string": if (optionValue !== "") { optionValue = Options.resolve(optionName, optionValue); await Options.validate(optionName, optionValue, this.#onDiagnostics); this.#commandLineOptions[optionDefinition.name] = optionValue; index++; break; } this.#onExpectsValue(optionName, optionDefinition.brand); break; case "range": if (optionValue !== "") { const optionValues = []; for (const range of Target.split(optionValue)) { await Options.validate(optionName, range, this.#onDiagnostics); const versions = await Target.expand(range, this.#onDiagnostics); optionValues.push(...versions); } this.#commandLineOptions[optionDefinition.name] = optionValues; index++; break; } this.#onExpectsValue(optionName, "string"); break; } return index; } #resolveOptionValue(target = "") { return target.startsWith("-") ? "" : target; } } class ConfigParser { #configFileOptions; #jsonScanner; #onDiagnostics; #options; #sourceFile; constructor(configOptions, optionGroup, sourceFile, jsonScanner, onDiagnostics) { this.#configFileOptions = configOptions; this.#jsonScanner = jsonScanner; this.#onDiagnostics = onDiagnostics; this.#sourceFile = sourceFile; this.#options = Options.for(optionGroup); } #onRequiresValue(optionName, optionBrand, jsonNode, isListItem) { const text = isListItem ? ConfigDiagnosticText.expectsListItemType(optionName, optionBrand) : ConfigDiagnosticText.optionValueMustBe(optionName, optionBrand); this.#onDiagnostics(Diagnostic.error(text, jsonNode.origin)); } async #parseValue(optionDefinition, isListItem = false) { let jsonNode; let optionValue; switch (optionDefinition.brand) { case "boolean": { jsonNode = this.#jsonScanner.read(); optionValue = jsonNode.getValue(); if (typeof optionValue !== "boolean") { this.#onRequiresValue(optionDefinition.name, optionDefinition.brand, jsonNode, isListItem); break; }