diff --git a/.changeset/typescript-7.md b/.changeset/typescript-7.md new file mode 100644 index 00000000..de8bff52 --- /dev/null +++ b/.changeset/typescript-7.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": minor +--- + +Support TypeScript 7 in the TypeScript check. TypeScript 7 has no compiler API in JavaScript, so the check failed to load it at all. The check now runs the native compiler through the API TypeScript 7 ships (`typescript/unstable/async`), in a process of its own that stays open while webpack watches and is told which files webpack saw change. It prints diagnostics the way `tsc --pretty` does. `build`, a writing `mode`, and compiler options written in the plugin are reported as unsupported under TypeScript 7. Over 600 files, a one-shot build went from 3.5 s on TypeScript 6 to 1.4–1.5 s, and a watch rebuild from 1.0–2.5 s to 0.7–0.8 s. diff --git a/README.md b/README.md index 65ea1dd2..f3f46b59 100644 --- a/README.md +++ b/README.md @@ -842,7 +842,8 @@ Not `--reporter`: the check asks for JSON and formats the results itself. ## TypeScript -Run with `{ use: "typescript" }`, and requires `typescript >= 5`. It type checks +Run with `{ use: "typescript" }`, and requires `typescript >= 5` (see +[TypeScript 7](#typescript-7) for the native compiler). It type checks the program a `tsconfig.json` describes, so every file that config includes is checked whether or not webpack built it, and a type error in a module nothing imports yet is still reported. Emit is forced off: webpack writes the output. @@ -893,6 +894,26 @@ new DiagnosticsPlugin({ }); ``` +### TypeScript 7 + +TypeScript 7 is a native compiler with no compiler API in JavaScript, so the +check talks to it through the API it ships instead (`typescript/unstable/async`). +It runs in a process of its own, which takes the place of +[`threads`](#threads), and is kept open while webpack watches: a rebuild tells it +which files webpack saw change. What it prints is what `tsc --pretty` prints. + +It checks the project as its config file describes it, so [`build`](#build), a +[`mode`](#mode) that writes, and compiler options written in the plugin are +reported as unsupported rather than ignored. Compiler options belong in the +`tsconfig.json` instead; `build` and a writing `mode` have no TypeScript 7 +counterpart. + +Over 600 files, a one-shot build with the check went from 3.5 s to 1.4–1.5 s +next to TypeScript 6, and webpack's own process from 6.4 to 0.6–0.8 s of CPU and +from 383 to 108 MB at its peak; the native compiler, checking the same project +on its own, takes 1.5–1.7 s of CPU. A rebuild after an edit went from 1.0–2.5 s +to 0.7–0.8 s. + ### `typescriptPath` - Type: diff --git a/package-lock.json b/package-lock.json index c0ceac66..f958e154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "diagnostics-webpack-plugin", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "diagnostics-webpack-plugin", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "jest-worker": "^30.5.1", @@ -40,6 +40,7 @@ "schema-utils": "^4.8.0", "stylelint": "^17.15.0", "typescript": "^6.0.3", + "typescript7": "npm:typescript@^7.0.2", "webpack": "^5.112.0" }, "engines": { @@ -5906,6 +5907,346 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", @@ -18169,6 +18510,42 @@ "node": ">=14.17" } }, + "node_modules/typescript7": { + "name": "typescript", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index acc0b4a0..15cd9029 100644 --- a/package.json +++ b/package.json @@ -43,13 +43,13 @@ "start": "npm run build -- -w", "clean": "del-cli dist types", "prebuild": "npm run clean", - "build:types": "tsc --declaration --emitDeclarationOnly --outDir types && prettier \"types/**/*.{ts,cts,mts}\" --write", + "build:types": "node ./node_modules/typescript/bin/tsc --declaration --emitDeclarationOnly --outDir types && prettier \"types/**/*.{ts,cts,mts}\" --write", "build": "npm-run-all -p \"build:**\"", "security": "npm audit --omit=dev", "lint:prettier": "prettier -w --list-different .", "lint:code": "eslint --cache .", "lint:spelling": "cspell \"**/*.*\"", - "lint:types": "tsc --pretty --noEmit", + "lint:types": "node ./node_modules/typescript/bin/tsc --pretty --noEmit", "lint:schema": "schema-utils precompile --check --minify src/options.json src/checks/biome.json src/checks/eslint.json src/checks/oxlint.json src/checks/stylelint.json src/checks/typescript.json", "lint": "npm-run-all -l -p \"lint:**\"", "fix:code": "npm run lint:code -- --fix", @@ -99,6 +99,7 @@ "schema-utils": "^4.8.0", "stylelint": "^17.15.0", "typescript": "^6.0.3", + "typescript7": "npm:typescript@^7.0.2", "webpack": "^5.112.0" }, "peerDependencies": { @@ -106,7 +107,7 @@ "eslint": "^9.0.0 || ^10.0.0", "oxlint": "^1.0.0", "stylelint": "^17.0.0", - "typescript": "^5.0.0 || ^6.0.0", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "webpack": "^5.106.0" }, "peerDependenciesMeta": { diff --git a/src/checks/typescript-native.js b/src/checks/typescript-native.js new file mode 100644 index 00000000..b23a30e6 --- /dev/null +++ b/src/checks/typescript-native.js @@ -0,0 +1,313 @@ +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +import { existsSync, readFileSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { getTypeScriptOptions } from "./typescript-program.js"; + +/** @typedef {import("../options.js").CheckOptions} Options */ +/** + * What the native compiler says of one problem. + * @typedef {{ fileName?: string, pos: number, end: number, code: number, category: number, text: string, messageChain?: NativeDiagnostic[] }} NativeDiagnostic + */ +/** + * What a run answers with, described as a worker describes it. + * @typedef {{ file?: string, code: string, severity: string, text: string, formatted: string }} Described + */ +/** + * A client of the native compiler and what it last saw of the project. + * @typedef {{ api: EXPECTED_ANY, snapshot: EXPECTED_ANY, files: Set }} Native + */ + +const nodeRequire = createRequire(import.meta.url); + +// TypeScript's own numbering: what is not an error is reported as a warning. +const ERROR = 1; + +/** + * The API TypeScript 7 offers in place of the compiler it no longer exposes to + * JavaScript, or nothing for an older TypeScript, which is checked in-process. + * @param {string} specifier where TypeScript is found + * @returns {Promise} the native API, if this is TypeScript 7 or later + */ +async function loadNative(specifier) { + let manifest; + + try { + manifest = nodeRequire.resolve(`${specifier}/package.json`); + } catch { + // A path to a module rather than to a package, as a test's stand-in is. + return undefined; + } + + const { version } = JSON.parse(readFileSync(manifest, "utf8")); + + if (Number(String(version).split(".")[0]) < 7) return undefined; + + // The package names itself, so its own exports resolve from inside it. + const api = createRequire(manifest).resolve("typescript/unstable/async"); + + return import(pathToFileURL(api).href); +} + +/** + * What only the JavaScript compiler can do: the native one reads a config file + * as it is written, and checks without building or writing anything. + * @param {Options} options the options the check was configured with + * @returns {string | undefined} what cannot be honoured, if anything + */ +function unsupported(options) { + if (options.build) return "`build`"; + + if (options.mode && options.mode !== "readonly") { + return `\`mode: "${options.mode}"\``; + } + + const overrides = { + ...getTypeScriptOptions(options), + ...options.compilerOptions, + }; + + if (Object.keys(overrides).length > 0) { + return `compiler options written in the plugin (${Object.keys(overrides) + .map((option) => `\`${option}\``) + .join(", ")})`; + } + + return undefined; +} + +/** + * The config file a check reads: the one named, or the nearest one above the + * context, as `tsc` finds it. + * @param {Options} options the options the check was configured with + * @returns {string} the config file + */ +function findConfigFile(options) { + const context = String(options.context); + + if (options.configFile) return resolve(context, String(options.configFile)); + + for (let directory = context; ; directory = dirname(directory)) { + const candidate = join(directory, "tsconfig.json"); + + if (existsSync(candidate)) return candidate; + + // Unreachable from the suite: every fixture sits under this repository's + // own config, which the search finds on its way up. + /* istanbul ignore next */ + if (dirname(directory) === directory) { + throw new Error( + `no 'tsconfig.json' was found above '${context}', and none was named by 'configFile'.`, + ); + } + } +} + +/** + * @param {NativeDiagnostic} diagnostic one of what a run found + * @returns {string} its message with every message it chains, as `tsc` joins them + */ +function flatten(diagnostic) { + return [ + diagnostic.text, + ...(diagnostic.messageChain || []).map(flatten), + ].join(" "); +} + +/** + * Printed the way `tsc --pretty` prints one: where it is, what it is, and the + * line it is on with the span underlined. + * @param {NativeDiagnostic} diagnostic one of what a run found + * @param {string} context what the file is named relative to + * @param {Map} texts the source of each file, read once + * @returns {string} the diagnostic, formatted + */ +function format(diagnostic, context, texts) { + const [color, category] = + diagnostic.category === ERROR ? [91, "error"] : [93, "warning"]; + const head = `\u001B[${color}m${category}\u001B[0m\u001B[90m TS${diagnostic.code}: \u001B[0m${flatten(diagnostic)}`; + + if (!diagnostic.fileName) return `${head}\n`; + + let text = texts.get(diagnostic.fileName); + + if (text === undefined) { + try { + text = readFileSync(diagnostic.fileName, "utf8"); + } catch /* istanbul ignore next */ { + // Deleted since it was checked: the next rebuild says so. + text = ""; + } + + texts.set(diagnostic.fileName, text); + } + + const before = text.slice(0, diagnostic.pos).split(/\r?\n/u); + const line = before.length; + const column = before[before.length - 1].length + 1; + const source = text.split(/\r?\n/u)[line - 1] || ""; + const width = Math.max( + 1, + Math.min(diagnostic.end - diagnostic.pos, source.length - column + 1), + ); + const gutter = String(line); + + return ( + `\u001B[96m${relative(context, diagnostic.fileName)}\u001B[0m:\u001B[93m${line}\u001B[0m:\u001B[93m${column}\u001B[0m - ${head}\n\n` + + `\u001B[7m${gutter}\u001B[0m ${source}\n` + + `\u001B[7m${" ".repeat(gutter.length)}\u001B[0m \u001B[91m${" ".repeat(column - 1)}${"~".repeat(width)}\u001B[0m\n` + ); +} + +/** + * Tells the native compiler what changed since it last looked: webpack knows, + * so nothing has to be read again to find out. + * @param {Native} native the client and what it last saw + * @param {EXPECTED_ANY} compiler the compiler the check runs for + * @returns {EXPECTED_ANY} the changes, in the shape `updateSnapshot` takes + */ +function changesSince(native, compiler) { + /** @type {string[]} */ + const changed = []; + /** @type {string[]} */ + const created = []; + + for (const file of compiler.modifiedFiles || []) { + if (native.files.has(file)) { + changed.push(file); + continue; + } + + try { + if (statSync(file).isFile()) created.push(file); + } catch /* istanbul ignore next */ { + // Gone again, which is what `removedFiles` says. + } + } + + return { + changed, + created, + deleted: [...(compiler.removedFiles || [])], + }; +} + +/** + * Checks the program the config file describes with the native compiler, which + * runs in a process of its own: webpack's thread only waits for the answer. + * @param {Native} native the client and what it last saw + * @param {Options} options the options the check was configured with + * @param {EXPECTED_ANY} compiler the compiler the check runs for + * @returns {Promise<{ diagnostics: Described[], files: string[], directories: string[] }>} what it found, and what it read to find it + */ +async function checkNative(native, options, compiler) { + const cannot = unsupported(options); + + if (cannot) { + throw new Error( + `TypeScript 7 checks a project as its config file describes it, so ${cannot} cannot be used with it.`, + ); + } + + const context = String(options.context); + const configFile = findConfigFile(options); + const previous = native.snapshot; + + native.snapshot = await native.api.updateSnapshot( + previous + ? { + openProjects: [configFile], + fileChanges: changesSince(native, compiler), + } + : { openProjects: [configFile] }, + ); + + if (previous) await previous.dispose(); + + const project = native.snapshot.getProject(configFile); + const { program } = project; + const kinds = { + syntactic: true, + semantic: true, + declaration: true, + global: true, + ...options.diagnosticOptions, + }; + const { compilerOptions } = project; + /** @type {NativeDiagnostic[][]} */ + const found = await Promise.all([ + program.getConfigFileParsingDiagnostics(), + program.getProgramDiagnostics(), + kinds.syntactic ? program.getSyntacticDiagnostics() : [], + kinds.global ? program.getGlobalDiagnostics() : [], + kinds.declaration && + (compilerOptions.declaration || compilerOptions.composite) + ? program.getDeclarationDiagnostics() + : [], + kinds.semantic ? program.getSemanticDiagnostics() : [], + ]); + /** @type {Map} */ + const texts = new Map(); + /** @type {Set} */ + const seen = new Set(); + /** @type {Described[]} */ + const diagnostics = []; + + for (const diagnostic of found.flat()) { + // One kind can repeat what another reported, as `tsc` deduplicates. + const key = `${diagnostic.fileName}\0${diagnostic.pos}\0${diagnostic.code}\0${diagnostic.text}`; + + if (seen.has(key)) continue; + + seen.add(key); + diagnostics.push({ + file: diagnostic.fileName, + // As `tsc` prints it, which is how `ignoreDiagnostics` names one too. + code: `TS${diagnostic.code}`, + severity: diagnostic.category === ERROR ? "error" : "warning", + text: flatten(diagnostic), + formatted: format(diagnostic, context, texts), + }); + } + + native.files = new Set(await program.getSourceFileNames()); + + return { + diagnostics, + // The config file decides which files the program holds, so reading it + // again is what a change to it takes. + files: [configFile, ...project.rootFiles], + // Where a file the program has never held can appear. + directories: [dirname(configFile)], + }; +} + +/** + * Starts a client of the native compiler, which spawns a process of its own. + * @param {EXPECTED_ANY} loaded what `loadNative` loaded + * @param {Options} options the options the check was configured with + * @returns {Native} the client, with nothing seen yet + */ +function startNative(loaded, options) { + return { + api: new loaded.API({ cwd: String(options.context) }), + snapshot: undefined, + files: new Set(), + }; +} + +/** + * @param {Native} native the client to stop, and the process it spawned + * @returns {Promise} once it is closed + */ +async function stopNative(native) { + if (native.snapshot) await native.snapshot.dispose(); + + await native.api.close(); +} + +export { checkNative, loadNative, startNative, stopNative }; diff --git a/src/checks/typescript.js b/src/checks/typescript.js index b819cb5a..83366e6b 100644 --- a/src/checks/typescript.js +++ b/src/checks/typescript.js @@ -6,6 +6,12 @@ import { createRequire } from "node:module"; import { countThreads, createSolo } from "../threads.js"; import { importFrom } from "../utils.js"; +import { + checkNative, + loadNative, + startNative, + stopNative, +} from "./typescript-native.js"; import { check, describe, getHeld, getSchemas } from "./typescript-program.js"; export { getTypeScriptOptions } from "./typescript-program.js"; @@ -16,6 +22,7 @@ export { getTypeScriptOptions } from "./typescript-program.js"; /** @typedef {import("./index.js").FormatterOption} FormatterOption */ /** @typedef {import("../options.js").CheckOptions} Options */ /** @typedef {import("../threads.js").Solo} Solo */ +/** @typedef {import("./typescript-native.js").Native} Native */ /** @typedef {EXPECTED_ANY} TypeScript */ /** @typedef {EXPECTED_ANY} Diagnostic */ @@ -85,6 +92,127 @@ function getWorker(compiler, id, options) { return worker; } +/** @type {WeakMap>} */ +const nativesByCompiler = new WeakMap(); + +/** @type {Map>} */ +const natives = new Map(); + +/** + * The client of the native compiler this check runs on, kept for the compiler + * it runs for so that the project it opened outlives one of its builds. + * @param {EXPECTED_ANY} compiler the compiler the check runs for + * @param {string} id a key unique to the check within that compiler + * @param {EXPECTED_ANY} loaded what `loadNative` loaded + * @param {Options} options the options the check was configured with + * @returns {Native} the client + */ +function getNative(compiler, id, loaded, options) { + let checks = nativesByCompiler.get(compiler); + + if (!checks) { + checks = new Map(); + nativesByCompiler.set(compiler, checks); + } + + let native = checks.get(id); + + if (!native) { + const started = startNative(loaded, options); + + native = started; + checks.set(id, started); + + // The compiler's process is the native compiler's too, so a watch closed + // without the compiler stops it as well, or it outlives the work. + const stop = async () => { + /** @type {Map} */ (checks).delete(id); + + await stopNative(started); + }; + + compiler.hooks.shutdown.tapPromise("DiagnosticsPlugin", stop); + compiler.hooks.watchClose.tap("DiagnosticsPlugin", () => { + stop(); + }); + } + + return native; +} + +/** + * The check on TypeScript 7, whose compiler runs in a process of its own and + * answers with what it found already described. + * @param {CheckContext} context check context + * @param {string} id a key unique to the check within its compiler + * @param {EXPECTED_ANY} loaded what `loadNative` loaded + * @returns {CheckInstance} typescript check + */ +function createNative({ options, compilation }, id, loaded) { + const { compiler } = compilation; + const native = getNative(compiler, id, loaded, options); + /** @type {string[]} */ + let read = []; + /** @type {string[]} */ + let directories = []; + let checked = false; + + return { + async lintFiles() { + if (checked) return []; + + checked = true; + + const found = await checkNative(native, options, compiler); + + read = found.files; + directories = found.directories; + + return found.diagnostics; + }, + readFiles() { + return read; + }, + readDirectories() { + return directories; + }, + async getResults(results) { + return results; + }, + filterResults(results, keep) { + return results.filter((one) => keep(one)); + }, + splitResults(results) { + return { + errors: results.filter((one) => one.severity === "error"), + warnings: results.filter((one) => one.severity !== "error"), + }; + }, + async getFormatter(formatter) { + if (typeof formatter === "function") { + return async (results) => String(await formatter(results)); + } + + return async (results) => + /** @type {Described[]} */ (results) + .map((one) => one.formatted) + .join("") + .trim(); + }, + // A build that has no next one keeps no project, and a compiler left + // running would hold the process open after webpack is done with it. + async cleanup() { + if (compiler.watchMode) return; + + const checks = nativesByCompiler.get(compiler); + + if (checks) checks.delete(id); + + await stopNative(native); + }, + }; +} + /** * Whether a run of this check can be handed to a worker. A `Diagnostic` holds * the source file it was found in, so a worker describes what it found rather @@ -107,6 +235,20 @@ async function create({ key, id: entry, options, compilation }) { // The program is this entry's: another check of the same tool reads the same // config file under options of its own. const id = `${key}\0${entry}`; + const specifier = String(options.typescriptPath || "typescript"); + let loading = natives.get(specifier); + + if (!loading) { + loading = loadNative(specifier); + natives.set(specifier, loading); + } + + const loaded = await loading; + + if (loaded) { + return createNative({ key, id: entry, options, compilation }, id, loaded); + } + const worker = canThread(options) ? getWorker(compilation.compiler, id, options) : null; diff --git a/test/typescript/fixtures/native-watch/index.js b/test/typescript/fixtures/native-watch/index.js new file mode 100644 index 00000000..aa624046 --- /dev/null +++ b/test/typescript/fixtures/native-watch/index.js @@ -0,0 +1 @@ +import "./trigger.js"; diff --git a/test/typescript/native.test.js b/test/typescript/native.test.js new file mode 100644 index 00000000..61866b6d --- /dev/null +++ b/test/typescript/native.test.js @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { afterEach, describe, it } from "node:test"; + +import { loadNative } from "../../src/checks/typescript-native.js"; + +import pack from "./utils/pack.js"; + +const require = createRequire(import.meta.url); +// TypeScript 7, installed beside the TypeScript the rest of the suite runs on. +const typescriptPath = dirname(require.resolve("typescript7/package.json")); + +const fixture = join(import.meta.dirname, "fixtures", "native-watch"); +const checked = join(fixture, "checked.ts"); +const configFile = join(fixture, "tsconfig.json"); +const trigger = join(fixture, "trigger.js"); + +describe("typescript 7", () => { + let watch; + + afterEach(() => { + if (watch) watch.close(); + + rmSync(checked, { force: true }); + rmSync(configFile, { force: true }); + rmSync(trigger, { force: true }); + }); + + it("should load the native API only for TypeScript 7 and later", async () => { + assert.ok(await loadNative(typescriptPath)); + assert.strictEqual(await loadNative("typescript"), undefined); + // A path to a module rather than to a package. + assert.strictEqual( + await loadNative(join(import.meta.dirname, "mock/typescript-recorder")), + undefined, + ); + }); + + it("should report nothing when the types are right", async () => { + const stats = await pack("good", { typescriptPath }).runAsync(); + + assert.strictEqual(stats.hasErrors(), false); + assert.strictEqual(stats.hasWarnings(), false); + }); + + it("should report a type error as a webpack error", async () => { + const stats = await pack("bad", { typescriptPath }).runAsync(); + const [{ message }] = stats.compilation.errors; + + const plain = message.replaceAll(/\u001B\[\d+m/gu, ""); + + assert.match(message, /\[typescript\]/u); + assert.match(plain, /used\.ts:1:14 - error TS2322:/u); + assert.match(message, /TS2322/u); + // A file nothing imports is checked too. + assert.match(message, /orphan\.ts/u); + // With the line it is on, as `tsc` prints it. + assert.match(message, /export const wrong: number = "not a number";/u); + }); + + it("should leave out the diagnostics whose code is ignored", async () => { + const stats = await pack("ignored", { + typescriptPath, + ignoreDiagnostics: [2322], + }).runAsync(); + const [{ message }] = stats.compilation.errors; + + assert.doesNotMatch(message, /TS2322/u); + assert.match(message, /TS2307/u); + }); + + it("should leave out a kind of diagnostic turned off", async () => { + const stats = await pack("bad", { + typescriptPath, + diagnosticOptions: { semantic: false }, + }).runAsync(); + + assert.strictEqual(stats.hasErrors(), false); + }); + + it("should format with a formatter written in the configuration", async () => { + const stats = await pack("bad", { + typescriptPath, + formatter: (/** @type {EXPECTED_ANY[]} */ results) => + results.map((one) => `${one.code} ${one.severity}`).join("|"), + }).runAsync(); + + assert.strictEqual( + stats.compilation.errors[0].message, + "[typescript] TS2322 error|TS2322 error", + ); + }); + + it("should say what it cannot do rather than ignore it", async () => { + for (const [options, named] of [ + [{ build: true }, /`build`/u], + [{ mode: "write-dts" }, /`mode: "write-dts"`/u], + [{ compilerOptions: { strict: false } }, /`strict`/u], + ]) { + const stats = await pack("bad", { + typescriptPath, + ...options, + }).runAsync(); + const [{ message }] = stats.compilation.errors; + + assert.match(message, /TypeScript 7/u); + assert.match(message, named); + } + }); + + it("should check again what a rebuild changed", (t, done) => { + writeFileSync( + configFile, + `${JSON.stringify({ + compilerOptions: { strict: true, skipLibCheck: true }, + include: ["*.ts"], + })}\n`, + ); + writeFileSync(trigger, "export const trigger = 1;\n"); + writeFileSync(checked, "export const wrong: string = 42;\n"); + + let fixed = false; + + watch = pack("native-watch", { typescriptPath }).watch({}, (err, stats) => { + assert.strictEqual(err, null); + + if (!fixed) { + // A rebuild webpack starts for what was written before it watched. + if (!stats.hasErrors()) return; + + assert.match(stats.compilation.errors[0].message, /TS2322/u); + fixed = true; + writeFileSync(checked, "export const right: string = '42';\n"); + + return; + } + + if (stats.hasErrors()) return; + + done(); + }); + }); +}); diff --git a/types/checks/typescript-native.d.ts b/types/checks/typescript-native.d.ts new file mode 100644 index 00000000..fc250c61 --- /dev/null +++ b/types/checks/typescript-native.d.ts @@ -0,0 +1,68 @@ +export type EXPECTED_ANY = any; +export type Options = import("../options.js").CheckOptions; +/** + * What the native compiler says of one problem. + */ +export type NativeDiagnostic = { + fileName?: string; + pos: number; + end: number; + code: number; + category: number; + text: string; + messageChain?: NativeDiagnostic[]; +}; +/** + * What a run answers with, described as a worker describes it. + */ +export type Described = { + file?: string; + code: string; + severity: string; + text: string; + formatted: string; +}; +/** + * A client of the native compiler and what it last saw of the project. + */ +export type Native = { + api: EXPECTED_ANY; + snapshot: EXPECTED_ANY; + files: Set; +}; +/** + * Checks the program the config file describes with the native compiler, which + * runs in a process of its own: webpack's thread only waits for the answer. + * @param {Native} native the client and what it last saw + * @param {Options} options the options the check was configured with + * @param {EXPECTED_ANY} compiler the compiler the check runs for + * @returns {Promise<{ diagnostics: Described[], files: string[], directories: string[] }>} what it found, and what it read to find it + */ +export function checkNative( + native: Native, + options: Options, + compiler: EXPECTED_ANY, +): Promise<{ + diagnostics: Described[]; + files: string[]; + directories: string[]; +}>; +/** + * The API TypeScript 7 offers in place of the compiler it no longer exposes to + * JavaScript, or nothing for an older TypeScript, which is checked in-process. + * @param {string} specifier where TypeScript is found + * @returns {Promise} the native API, if this is TypeScript 7 or later + */ +export function loadNative(specifier: string): Promise; +/** + * Starts a client of the native compiler, which spawns a process of its own. + * @param {EXPECTED_ANY} loaded what `loadNative` loaded + * @param {Options} options the options the check was configured with + * @returns {Native} the client, with nothing seen yet + */ +export function startNative(loaded: EXPECTED_ANY, options: Options): Native; +/** + * @param {Native} native the client to stop, and the process it spawned + * @returns {Promise} once it is closed + */ +export function stopNative(native: Native): Promise; diff --git a/types/checks/typescript.d.ts b/types/checks/typescript.d.ts index df7dd7f6..c8bcc390 100644 --- a/types/checks/typescript.d.ts +++ b/types/checks/typescript.d.ts @@ -18,6 +18,7 @@ export type Format = import("./index.js").Format; export type FormatterOption = import("./index.js").FormatterOption; export type Options = import("../options.js").CheckOptions; export type Solo = import("../threads.js").Solo; +export type Native = import("./typescript-native.js").Native; export type TypeScript = EXPECTED_ANY; export type Diagnostic = EXPECTED_ANY; /**