tstyche
Version:
Everything You Need for Type Testing.
1,427 lines (1,400 loc) • 247 kB
JavaScript
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;
}