diff --git a/.changeset/assets-named-by-typescript.md b/.changeset/assets-named-by-typescript.md new file mode 100644 index 00000000..e3163fc8 --- /dev/null +++ b/.changeset/assets-named-by-typescript.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": patch +--- + +Tell an asset import from a script one by the extensions TypeScript itself reads, rather than by a list of asset kinds: an import of any other kind of file (`./data.toml`, say) no longer makes every rebuild lint every file again. diff --git a/.changeset/cli-checks-one-run.md b/.changeset/cli-checks-one-run.md new file mode 100644 index 00000000..fc723bb4 --- /dev/null +++ b/.changeset/cli-checks-one-run.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": patch +--- + +Hand Biome and oxlint every file at once when webpack is done building them, rather than in batches while it builds: each batch was a process of its own reading the configuration, and for Biome the project, again. diff --git a/.changeset/import-graph-typescript-7.md b/.changeset/import-graph-typescript-7.md new file mode 100644 index 00000000..1aeb19e0 --- /dev/null +++ b/.changeset/import-graph-typescript-7.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": patch +--- + +Lint every file again, rather than fail, where a project's TypeScript is the native TypeScript 7, which has no API to read what a file imports through: a check reading across files failed with `Cannot read properties of undefined (reading 'readFile')`. diff --git a/.changeset/ts-share-program-across-compilers.md b/.changeset/ts-share-program-across-compilers.md new file mode 100644 index 00000000..912b9bd8 --- /dev/null +++ b/.changeset/ts-share-program-across-compilers.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": minor +--- + +Share the TypeScript check's worker, and the program it holds, between the compilers of a `MultiCompiler` whose TypeScript checks are configured alike. Before, each compiler built and kept its own program. Over 600 files, two compilers building one after the other went through a watch with one rebuild in 5.6–5.8 s, 9.8–10.4 CPU-seconds and 467–483 MB, against 9.7–10.3 s, 18.7–20.0 CPU-seconds and 803–853 MB. Under TypeScript 7 the native compiler's process is shared the same way. diff --git a/.changeset/typescript-7.md b/.changeset/typescript-7.md new file mode 100644 index 00000000..5f1d9e12 --- /dev/null +++ b/.changeset/typescript-7.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": minor +--- + +Support TypeScript 7 in the TypeScript check, from 7.1. 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 ships, `typescript/async`, in a process of its own that stays open while webpack watches and is told which files webpack saw change, and prints diagnostics with TypeScript's own formatter. `build`, a writing `mode`, and compiler options written in the plugin are reported as unsupported under TypeScript 7. TypeScript 7.0, which shipped that API only as an unstable preview, is reported as having no API to check with. Over 600 files, a one-shot build took 1.2–1.3 s on TypeScript 7.1, against 3.8 s on TypeScript 6 without its saved state. diff --git a/.cspell.json b/.cspell.json index ee1ec69d..e3989856 100644 --- a/.cspell.json +++ b/.cspell.json @@ -26,9 +26,7 @@ "stylelint's", "stylelintcache", "stylelintignore", - "stylelintrc", - "pcss", - "styl" + "stylelintrc" ], "ignorePaths": [ "**/__snapshots__/**", diff --git a/README.md b/README.md index aa62d134..b277dce4 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,12 @@ pool of workers — so the option means the same thing whatever is being checked and a check added later gets it for nothing. ESLint threads its own work from 9.34.0 under flat config; Stylelint threads none of its own, so it is pooled. TypeScript reads a program whole rather than a file at a time, so it is run on -one worker: what a thread buys it is webpack's own thread back. +one worker: what a thread buys it is webpack's own thread back. The compilers of +a `MultiCompiler` whose TypeScript checks are configured alike share that worker +and the program it holds, rather than each building and keeping one of their +own. Over 600 files, two compilers built one after the other (`parallelism: 1`) +went through a watch with one rebuild in 5.6–5.8 s and 9.8–10.4 s of CPU at +467–483 MB, against 9.7–10.3 s, 18.7–20.0 s and 803–853 MB with a worker each. Prefer `"auto"` over a count. A check sizes `"auto"` against the machine, where a fixed number can end up competing with webpack for the same cores: over three @@ -383,7 +388,8 @@ drops it before webpack sees it — and this holds for every check, whatever too it runs. A change that could reach files some other way — a declaration file nothing imports, a `declare global`, a config file, an import that resolves to nothing (a stylesheet, an image or another asset webpack bundles aside) — lints -every file again, as does a project without TypeScript. There, +every file again, as does a project without TypeScript, or with TypeScript 7, +whose native compiler has no JavaScript API to read imports through. There, an ESLint check with `cache: true` written down keeps ESLint's own cache, which answers for a file whose import changed from before the change. @@ -784,6 +790,10 @@ new DiagnosticsPlugin({ checks: ["biome"] }); Like the oxlint check this runs a binary and reads the JSON it answers with, which Biome calls an experimental reporter — a release of its own may move the shape, and it is the only reporter carrying the severities this check needs. +A build hands it every file at once when webpack is done building them, rather +than in batches while webpack builds: each run reads the configuration and the +project again. Over 1,500 files that took the CPU of +the processes from 2.9 s to 2.2 s, and for oxlint from 1.2–1.7 s to 0.3 s. Biome's project and types rules — `noImportCycles`, `noFloatingPromises` — answer for a file from the files it imports, and which rules those are is not @@ -844,7 +854,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. @@ -906,6 +917,25 @@ 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/async`, from +TypeScript 7.1. TypeScript 7.0 shipped that API only as an unstable preview, +which the check does not use. The compiler 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. Diagnostics are +printed by TypeScript's own formatter, as `tsc --pretty` prints them. + +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 took 1.2–1.3 s on TypeScript +7.1, against 3.8 s on TypeScript 6 without its saved state and 2.0–2.1 s with it. + ### `typescriptPath` - Type: diff --git a/package-lock.json b/package-lock.json index c0ceac66..b3680598 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.1.0-beta", "webpack": "^5.112.0" }, "engines": { @@ -54,7 +55,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": { @@ -5906,6 +5907,363 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.1.0-beta.tgz", + "integrity": "sha512-DXDG+jIWxBHGdoO70tcHON33rCnSzsEZj9P13oyf/r/KEGIo6awsdSNVNjAw0uMwEpqRu75IrgCcW58YcVkZHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-android-arm64": { + "version": "7.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-android-arm64/-/typescript-android-arm64-7.1.0-beta.tgz", + "integrity": "sha512-UZx6PxoQavuvaVTrSf3aw0Oy/J/uJpRylipA0kzc038jMvZXh0UtE8vnQp20FqtdgD5bhXZ0aJrRJyoTSUO58Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.1.0-beta.tgz", + "integrity": "sha512-q7mxblcyX0E7hXsY4k+Q6KWKK5mGmfVq+BDesdTgRVgjodk27aIxER77Zyj9+TgSTlCje8F7+JM6f6hl2BuNdw==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.1.0-beta.tgz", + "integrity": "sha512-D5EYqG5Wm/guw9+0j8+Mn1Jbr/gfS/VtFTuoH8PJtFbcqeDaQGMulc7SW8q51QG6Y3wGOGX3nbsTOw6fIKWWAA==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.1.0-beta.tgz", + "integrity": "sha512-9A0XX0tcnH9qPdolSKKzSjTb93dlB0UEcAJbHYEsrFX2fkFNSum9+dvkQQJ9vLc+7vxHetDPujq9Qe6wx+Xhow==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.1.0-beta.tgz", + "integrity": "sha512-EDf/dBHfY0TxX3NmUTek0hbffeyZlh3+ls6WQW3ICB8vYxwYLFnzg7wkgEUvl9eRlpiJ/nmc2S8arPMzINJ7rg==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.1.0-beta.tgz", + "integrity": "sha512-AvN9HmzOQh46G4312ooSgMHhbB7DtnLwg8D9mo324X49B7weMwtJrBG1MxT9IyI8kzDd7YRtW+1hWGun7WG7Qw==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.1.0-beta.tgz", + "integrity": "sha512-hNqQBgQ5b5oGqN76QqbAU6CwYhJZ6iOcWc2kPTUs2OentgpxZktETziH6KIyRFJPaiBncognwn2ZLu3DNhHiWw==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.1.0-beta.tgz", + "integrity": "sha512-xbUustlBrFflt2pNEyG5ye+d4WGPc/rfr2xK29MXeobPSw297JqwuKRrvbKM5o3fE/eUM/c+urVm6jQ3IaMVTw==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.1.0-beta.tgz", + "integrity": "sha512-fS4/G7/0bXZ3NSq3nBMAXyg65I3tO4S7j08VNQ2g1aNC/qq79gE7Qne1BNjVsjMFfHBVBFjQYoTQCjmVU2iFlg==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.1.0-beta.tgz", + "integrity": "sha512-0upCCxYPX8X6gOwkvfv+Ee7/ufyy7k97mTrGE0e5BxLlnUEa+w+rtgnv2dsKcxFdwdeZYVlZKCqgN/xCBV08+A==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.1.0-beta.tgz", + "integrity": "sha512-0jKU9YRgOPRgiy5V/UWv37kB53PaTpIbwGCbbOMUPPAkrjVRNOIAJaz7Wa7s3H7mdDebX1LopUEPEJR+7Xa7zg==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.1.0-beta.tgz", + "integrity": "sha512-j+d04s2kxaPQh6DLPb01sknAQfzsrdSZy15jBRHyLEZoaaQ19dVjbFRlkNVvYwUG7ZdP8+y5b/7RmPxdtHUKdQ==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.1.0-beta.tgz", + "integrity": "sha512-kcD51ihf+pnI5KT4uQQYG04OpwznaZYBlIQhDhl8fflFd27NktxXsUeguxZyvUMyWfum7IF2bBS6Jv7ox0AgFQ==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.1.0-beta.tgz", + "integrity": "sha512-8W85YyoGK+rgGoSU9jJQUpELbrKcZcbdJk2jPNQ6UKDKI7ryPlPFTrV2jCE5RQuv4Tt96hMDTc33s4YfRrLFBA==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.1.0-beta.tgz", + "integrity": "sha512-iGsTE0ve2PG0AcyZinwAR0LnGEZhu/y4dceS0hZsYCaKdYETvQtvtlNWlmYKempsBr6C7Il7dMXHVk+VFm9u0g==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.1.0-beta.tgz", + "integrity": "sha512-Z2FERdHB9fAKI+KGqdWn04O7uNlnB+VYiEm6INE1aZuzzLNd3KBqQ6IpKUJF2ny4GigGU6nVu75XD9tAUODPng==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.1.0-beta.tgz", + "integrity": "sha512-NVG3qeyCq/rJEOhaRjR3LsWgjrjo5vJW25omVLeHAVnpKIG4pE4cenwwKGR06DiJ1s0QTOymcJeKXMEpsiNBVg==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.1.0-beta.tgz", + "integrity": "sha512-ovAdoWXEn2B5/fyWG69pLU3FWmQwPWOjQV5TFbcxtpnOv+R3g8ySGHl1NYRq7Lb/GYdFDvaGcZ+SiU7iKuiMsA==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.1.0-beta.tgz", + "integrity": "sha512-lJbo71IEOfL7ZZIZnArRaOLqjn9BAfoSzyUQn0aSjfSpDZL4ru0afUsdBfo6PQEjD6ssdmi0n/RwaPADX6bsvQ==", + "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.1.0-beta", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.1.0-beta.tgz", + "integrity": "sha512-UKcHvbybY2tDTvFW2b1vwiAvDlIRooo69lh8CXWVhOQejcvpM5+IgbiFW9EIwR5Wi5X/v+BdN7gdWbigBuJ3rw==", + "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 +18527,43 @@ "node": ">=14.17" } }, + "node_modules/typescript7": { + "name": "typescript", + "version": "7.1.0-beta", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.1.0-beta.tgz", + "integrity": "sha512-cjWjZAJDsmZzsNC5GwHNlW0YPM65jZa5oR0KyQcYX3KSVIfTvtBq//vewISyatKk4OLi5grZjHhIVE9fdrw1UQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.1.0-beta", + "@typescript/typescript-android-arm64": "7.1.0-beta", + "@typescript/typescript-darwin-arm64": "7.1.0-beta", + "@typescript/typescript-darwin-x64": "7.1.0-beta", + "@typescript/typescript-freebsd-arm64": "7.1.0-beta", + "@typescript/typescript-freebsd-x64": "7.1.0-beta", + "@typescript/typescript-linux-arm": "7.1.0-beta", + "@typescript/typescript-linux-arm64": "7.1.0-beta", + "@typescript/typescript-linux-loong64": "7.1.0-beta", + "@typescript/typescript-linux-mips64el": "7.1.0-beta", + "@typescript/typescript-linux-ppc64": "7.1.0-beta", + "@typescript/typescript-linux-riscv64": "7.1.0-beta", + "@typescript/typescript-linux-s390x": "7.1.0-beta", + "@typescript/typescript-linux-x64": "7.1.0-beta", + "@typescript/typescript-netbsd-arm64": "7.1.0-beta", + "@typescript/typescript-netbsd-x64": "7.1.0-beta", + "@typescript/typescript-openbsd-arm64": "7.1.0-beta", + "@typescript/typescript-openbsd-x64": "7.1.0-beta", + "@typescript/typescript-sunos-x64": "7.1.0-beta", + "@typescript/typescript-win32-arm64": "7.1.0-beta", + "@typescript/typescript-win32-x64": "7.1.0-beta" + } + }, "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..3f6b45c1 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.1.0-beta", "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.1.0-0", "webpack": "^5.106.0" }, "peerDependenciesMeta": { diff --git a/src/check.js b/src/check.js index be7398d5..06601e07 100644 --- a/src/check.js +++ b/src/check.js @@ -6,6 +6,7 @@ import { isAbsolute, join, relative } from "node:path"; import picomatch from "picomatch"; import DiagnosticError from "./DiagnosticError.js"; +import { keptFor } from "./compiler-state.js"; import { reportedAs } from "./options.js"; import { toPosixPath } from "./utils.js"; @@ -24,11 +25,14 @@ import { toPosixPath } from "./utils.js"; /** @typedef {{ lint: (files: string[]) => void, keep: (files: string[]) => void, report: () => Promise, detach: (report: Promise) => void }} Runner */ /** @typedef {Map} ResultStore */ -/** @type {WeakMap>} */ -const resultStores = new WeakMap(); - -/** @type {WeakMap>>} */ -const detachedReports = new WeakMap(); +/** + * The runs of a compiler's checks left to finish on their own. + * @param {Compilation} compilation compilation + * @returns {Map>} each one's report, by check + */ +function detachedReportsOf(compilation) { + return keptFor(compilation.compiler, "detached-reports", () => new Map()); +} // A run of a check that is still going, so that two compilers over the same // files drive the tool once. It is kept only while it runs: what comes after @@ -85,22 +89,7 @@ function sharingKey(name, options) { * @returns {ResultStore} what the check last found in every file it covered */ function getResultStore(compilation, check) { - const { compiler } = compilation; - let stores = resultStores.get(compiler); - - if (!stores) { - stores = new Map(); - resultStores.set(compiler, stores); - } - - let store = stores.get(check); - - if (!store) { - store = new Map(); - stores.set(check, store); - } - - return store; + return keptFor(compilation.compiler, `results\0${check}`, () => new Map()); } /** @@ -111,10 +100,7 @@ function getResultStore(compilation, check) { * @returns {Promise | undefined} the report still to land, if there is one */ function takeDetachedReport(compilation, check) { - const reports = detachedReports.get(compilation.compiler); - - if (!reports) return undefined; - + const reports = detachedReportsOf(compilation); const report = reports.get(check); reports.delete(check); @@ -490,14 +476,7 @@ function createCheckRunner(key, { id, name, adapter, options }, compilation) { function detach(report) { detached = true; - let reports = detachedReports.get(compilation.compiler); - - if (!reports) { - reports = new Map(); - detachedReports.set(compilation.compiler, reports); - } - - reports.set( + detachedReportsOf(compilation).set( checkKey, report.then( () => {}, @@ -509,4 +488,6 @@ function createCheckRunner(key, { id, name, adapter, options }, compilation) { return { detach, keep, lint, report }; } +export { sharingKey }; + export default createCheckRunner; diff --git a/src/checks/biome.js b/src/checks/biome.js index 2d49f578..c3bd0a34 100644 --- a/src/checks/biome.js +++ b/src/checks/biome.js @@ -167,5 +167,9 @@ export default { // read for the answer the way oxlint's can. A rebuild lints again what a // change reaches, which a run of Biome barely notices; a project using none // of those rules can write `readsAcrossFiles: false`. + // Each run is a process of its own that reads the configuration and the + // project again, so a build hands it every file at once rather than a batch + // at a time while webpack builds. + lintsOnce: () => true, readsAcrossFiles: () => true, }; diff --git a/src/checks/oxlint.js b/src/checks/oxlint.js index ca0a73c0..99478f84 100644 --- a/src/checks/oxlint.js +++ b/src/checks/oxlint.js @@ -274,6 +274,10 @@ export default { create, resultPath: (/** @type {EXPECTED_ANY} */ result) => /** @type {FileResult} */ (result).filename, + // Each run is a process of its own that reads the configuration again, so a + // build hands it every file at once rather than a batch at a time while + // webpack builds. + lintsOnce: () => true, readsAcrossFiles, }; diff --git a/src/checks/typescript-native.js b/src/checks/typescript-native.js new file mode 100644 index 00000000..f996a07d --- /dev/null +++ b/src/checks/typescript-native.js @@ -0,0 +1,307 @@ +// 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, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { toPosixPath } from "../utils.js"; + +import { getTypeScriptOptions } from "./typescript-program.js"; + +/** @typedef {import("../options.js").CheckOptions} Options */ +/** + * What the native compiler says of one problem. + * @typedef {{ fileName?: string, 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 {{ loaded: EXPECTED_ANY, api: EXPECTED_ANY, snapshot: EXPECTED_ANY, files: Set, turn: Promise }} Native + */ + +const nodeRequire = createRequire(import.meta.url); + +/** + * The API a TypeScript with a native compiler ships in place of a compiler + * API in JavaScript, or nothing for a TypeScript checked in-process. Told by + * what the package exports rather than by its version. + * @param {string} specifier where TypeScript is found + * @returns {Promise} the native API, if the package ships one + */ +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 { name, exports } = JSON.parse(readFileSync(manifest, "utf8")); + + if (!exports || !exports["./async"]) return undefined; + + // The package names itself, so its own exports resolve from inside it. + const api = createRequire(manifest).resolve(`${name}/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; + + 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 + */ +function flatten(diagnostic) { + return [ + diagnostic.text, + ...(diagnostic.messageChain || []).map(flatten), + ].join(" "); +} + +/** + * A path as the native compiler and webpack can both be matched by: webpack + * spells one with the platform's separators, the compiler with forward + * slashes, and Windows tells no case apart. + * @param {string} file a path + * @returns {string} what it is known by + */ +function keyOf(file) { + const posix = toPosixPath(resolve(file)); + + return process.platform === "win32" ? posix.toLowerCase() : posix; +} + +/** + * 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 `fileNotifications` takes + */ +function changesSince(native, compiler) { + /** @type {string[]} */ + const changed = []; + /** @type {string[]} */ + const created = []; + + for (const file of compiler.modifiedFiles || []) { + if (native.files.has(keyOf(file))) { + changed.push(file); + continue; + } + + // Gone again, which is what `removedFiles` says, or a folder. + const stat = statSync(file, { throwIfNoEntry: false }); + + if (stat && stat.isFile()) created.push(file); + } + + return { + changed, + created, + deleted: [...(compiler.removedFiles || [])], + }; +} + +/** + * One run of `checkNative`, with the snapshot to itself. + * @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 checkOnce(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 configFile = findConfigFile(options); + const previous = native.snapshot; + + native.snapshot = previous + ? await previous.update({ + openProjects: [configFile], + fileNotifications: changesSince(native, compiler), + // Brought up to date now rather than on the first question asked. + ensurePrograms: true, + }) + : await native.api.createSnapshot({ openProjects: [configFile] }); + + if (previous) await previous.dispose(); + + const project = native.snapshot.getConfiguredProject(configFile); + const { program, parsedCommandLine } = project; + const kinds = { + syntactic: true, + semantic: true, + declaration: true, + global: true, + ...options.diagnosticOptions, + }; + const compilerOptions = parsedCommandLine.options; + /** @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() : [], + ]); + const { DiagnosticCategory, formatDiagnosticsWithColorAndContext } = + native.loaded; + /** @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 = JSON.stringify(diagnostic); + + 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}`, + // What is not an error is reported as a warning. + severity: + diagnostic.category === DiagnosticCategory.Error ? "error" : "warning", + text: flatten(diagnostic), + formatted: formatDiagnosticsWithColorAndContext([diagnostic], native.api), + }); + } + + // The config file and every one it extends, which decide which files the + // program holds, so that a change to one of them is told as a change too. + const configs = await program.getConfigFileNames(); + + native.files = new Set( + [...(await program.getSourceFileNames()), ...configs].map(keyOf), + ); + + return { + diagnostics, + files: [...configs, ...parsedCommandLine.fileNames], + // Where a file the program has never held can appear. + directories: [dirname(configFile)], + }; +} + +/** + * 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 + */ +function checkNative(native, options, compiler) { + // Compilers sharing the process take turns: a run replaces the snapshot and + // disposes of the last, which another run may still be reading. + const run = native.turn.then(() => checkOnce(native, options, compiler)); + + native.turn = run.then( + () => {}, + () => {}, + ); + + return run; +} + +/** + * 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 { + loaded, + api: new loaded.API({ cwd: String(options.context) }), + snapshot: undefined, + files: new Set(), + turn: Promise.resolve(), + }; +} + +/** + * @param {Native} native the client to stop, and the process it spawned + * @returns {Promise} once it is closed + */ +async function stopNative(native) { + // A run still going is let finish rather than left reading a snapshot gone. + await native.turn; + + if (native.snapshot) await native.snapshot.dispose(); + + await native.api.close(); +} + +export { checkNative, loadNative, startNative, stopNative }; diff --git a/src/checks/typescript-program.js b/src/checks/typescript-program.js index 4448564b..b3a4f00f 100644 --- a/src/checks/typescript-program.js +++ b/src/checks/typescript-program.js @@ -6,6 +6,7 @@ import { mkdirSync, statSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; +import { keptFor } from "../compiler-state.js"; import { omitPluginOptions } from "../utils.js"; /** @typedef {import("../options.js").CheckOptions} Options */ @@ -17,7 +18,7 @@ import { omitPluginOptions } from "../utils.js"; * hands them back, and the program that type checked them. * What a run of the check answers with: what it found, and what a watcher has * to follow for it to answer the same way again. - * @typedef {{ diagnostics: Diagnostic[], host: EXPECTED_ANY, files: string[], directories: string[], writes: string[] }} Found + * @typedef {{ diagnostics: Diagnostic[], host: EXPECTED_ANY, files: string[], directories: string[], writes: string[], extensions: string[] }} Found */ /** @@ -26,9 +27,6 @@ import { omitPluginOptions } from "../utils.js"; const nodeRequire = createRequire(import.meta.url); -/** @type {WeakMap>} */ -const heldByCompiler = new WeakMap(); - /** * A program is kept for the compiler that built it, so that a rebuild type * checks what changed rather than the project over again. @@ -37,32 +35,18 @@ const heldByCompiler = new WeakMap(); * @returns {Held} what the last compilation left behind */ function getHeld(compiler, id) { - let checks = heldByCompiler.get(compiler); - - if (!checks) { - checks = new Map(); - heldByCompiler.set(compiler, checks); - } - - let held = checks.get(id); - - if (!held) { - held = { - signature: "", - files: new Map(), - seen: new Set(), - missing: new Set(), - host: undefined, - program: undefined, - programs: new Map(), - readAt: 0, - dropped: new Map(), - declarations: new Map(), - }; - checks.set(id, held); - } - - return held; + return keptFor(compiler, `typescript-held\0${id}`, () => ({ + signature: "", + files: new Map(), + seen: new Set(), + missing: new Set(), + host: undefined, + program: undefined, + programs: new Map(), + readAt: 0, + dropped: new Map(), + declarations: new Map(), + })); } /** @@ -226,6 +210,23 @@ function writing(options) { }; } +/** + * What a file the config file's `include` picks up ends in, as TypeScript + * says for the options it reads them under: a script appearing in one of the + * directories the config file covers is one a rebuild has to check. + * @param {TypeScript} ts the loaded TypeScript + * @param {EXPECTED_ANY} compilerOptions the options the program is built with + * @returns {string[]} the extensions, with their dots + */ +function extensionsOf(ts, compilerOptions) { + return ts + .getSupportedExtensionsWithJsonIfResolveJsonModule( + compilerOptions, + ts.getSupportedExtensions(compilerOptions), + ) + .flat(); +} + /** * Every project a config file references, built the way `tsc -b` does — which * is what a reference is for: a project reads the declarations of the one it @@ -393,7 +394,15 @@ function buildSolution(ts, options, configFile, host, unrecoverable, held) { if (!held.seen.has(file)) held.files.delete(file); } - return { diagnostics, host, files, directories, writes }; + // Each project reads its own; a script any of them reads is one to check. + return { + diagnostics, + host, + files, + directories, + writes, + extensions: extensionsOf(ts, { allowJs: true, resolveJsonModule: true }), + }; } /** @@ -485,6 +494,14 @@ function declarationDiagnostics(held, program) { * @returns {Found} what it found, and what it read to find it */ function check(ts, options, held) { + // A native compiler ships `typescript/async`, which `loadNative` takes; one + // shipping neither has nothing to check through. + if (typeof ts.createProgram !== "function") { + throw new Error( + `TypeScript ${ts.version} offers no compiler API to check with, and no \`typescript/async\` either: TypeScript 7 is checked through \`typescript/async\` from 7.1.`, + ); + } + const context = String(options.context); const configFile = options.configFile || @@ -545,6 +562,7 @@ function check(ts, options, held) { files: [configFile], directories: [], writes: [], + extensions: [], }; } @@ -614,6 +632,23 @@ function check(ts, options, held) { global: true, ...options.diagnosticOptions, }; + // The config file decides which files the program holds, so reading it + // again is what a change to it takes. + const files = [configFile, ...extended, ...parsed.fileNames]; + // What `include` covers, which is where a file the program has never held + // can appear. + const directories = Object.keys(parsed.wildcardDirectories || {}); + const extensions = extensionsOf(ts, parsed.options); + /** @type {string[]} */ + const writes = overrides.noEmit + ? [] + : [ + parsed.options.outDir, + parsed.options.declarationDir, + parsed.options.tsBuildInfoFile, + ] + .filter(Boolean) + .map(String); const diagnostics = [ ...unrecoverable, ...ts.sortAndDeduplicateDiagnostics([ @@ -629,9 +664,6 @@ function check(ts, options, held) { ]), ]; - /** @type {string[]} */ - const writes = []; - if (stateFile) { try { held.program.emitBuildInfo( @@ -646,14 +678,6 @@ function check(ts, options, held) { } if (!overrides.noEmit) { - for (const written of [ - parsed.options.outDir, - parsed.options.declarationDir, - parsed.options.tsBuildInfoFile, - ]) { - if (written) writes.push(String(written)); - } - held.program.emit( undefined, ( @@ -671,17 +695,7 @@ function check(ts, options, held) { if (!held.seen.has(file)) held.files.delete(file); } - return { - diagnostics, - host, - // The config file decides which files the program holds, so reading it - // again is what a change to it takes. - files: [configFile, ...extended, ...parsed.fileNames], - // What `include` covers, which is where a file the program has never held - // can appear. - directories: Object.keys(parsed.wildcardDirectories || {}), - writes, - }; + return { diagnostics, host, files, directories, writes, extensions }; } /** * What a diagnostic says, as data: a `Diagnostic` holds the source file it was diff --git a/src/checks/typescript-worker.js b/src/checks/typescript-worker.js index 257a14eb..f370bee1 100644 --- a/src/checks/typescript-worker.js +++ b/src/checks/typescript-worker.js @@ -70,6 +70,7 @@ async function lintFiles() { writes: found.writes, missing: [...held.missing], seen: [...held.seen], + extensions: found.extensions, }; } diff --git a/src/checks/typescript.js b/src/checks/typescript.js index 632f6148..d4378131 100644 --- a/src/checks/typescript.js +++ b/src/checks/typescript.js @@ -3,9 +3,17 @@ import { createRequire } from "node:module"; +import { sharingKey } from "../check.js"; +import { acquire, keptFor, release } from "../compiler-state.js"; import { countThreads, createSolo } from "../threads.js"; import { importFrom, toPosixPath } 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 +24,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 */ @@ -35,16 +44,11 @@ export { getTypeScriptOptions } from "./typescript-program.js"; * @property {string[]} missing the paths an import resolved to nothing through * @property {Set} reads every path it read or looked for, as posix paths * @property {string[]} folders where a file the program does not hold yet would be found, as posix paths ending in a slash + * @property {string[]} extensions what a file the config file picks up ends in */ const nodeRequire = createRequire(import.meta.url); -// What a file the config file's `include` would pick up ends in. -const SCRIPT = /\.(?:[cm]?[jt]sx?|json)$/u; - -/** @type {WeakMap>} */ -const runsByCompiler = new WeakMap(); - /** * Whether a rebuild can have changed what the program says: a file it read or * looked for changed or went, or a script appeared where its config file would @@ -60,69 +64,87 @@ function reaches(run, changed) { return ( run.reads.has(file) || - (SCRIPT.test(file) && + (run.extensions.some((extension) => file.endsWith(extension)) && run.folders.some((folder) => file.startsWith(folder))) ); }); } -/** @type {WeakMap>} */ -const workersByCompiler = new WeakMap(); - -/** - * @param {EXPECTED_ANY} compiler the compiler the check ran for - * @param {string} id a key unique to the check within that compiler - */ -function dropWorker(compiler, id) { - const checks = workersByCompiler.get(compiler); - - if (checks) checks.delete(id); -} +/** @type {Map>} */ +const natives = new Map(); /** - * The one worker this check runs on, kept for the compiler it runs for so that - * the program it holds outlives the compilation that built it. - * @param {EXPECTED_ANY} compiler the compiler the check runs for - * @param {string} id a key unique to the check within that compiler - * @param {Options} options the options the check was configured with - * @returns {Solo | null} the worker, or nothing when it cannot be handed over + * 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 getWorker(compiler, id, options) { - let checks = workersByCompiler.get(compiler); - - if (!checks) { - checks = new Map(); - workersByCompiler.set(compiler, checks); - } - - let worker = checks.get(id); +function createNative({ options, compilation }, id, loaded) { + const { compiler } = compilation; + // The project the native compiler opened outlives one of the builds it + // answers for, and compilers checking it alike share the process. + const key = `typescript-native\0${sharingKey("typescript", options) || id}`; + const native = /** @type {Native} */ ( + acquire(compiler, key, () => startNative(loaded, options), stopNative) + ); + /** @type {string[]} */ + let read = []; + /** @type {string[]} */ + let directories = []; + let checked = false; - if (!worker) { - const started = createSolo(nodeRequire.resolve("./typescript-worker.js"), [ - options, - ]); + return { + async lintFiles() { + if (checked) return []; - if (!started) return null; + checked = true; - worker = started; - checks.set(id, worker); + const found = await checkNative(native, options, compiler); - // The program is the worker's, so in a watch it lives as long as the - // compiler does rather than as long as one of its builds. A watch that is - // closed without the compiler ends it too, or the thread outlives the work. - const stop = async () => { - dropWorker(compiler, id); + read = found.files; + directories = found.directories; - await /** @type {Solo} */ (worker).end(); - }; + 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)); + } - compiler.hooks.shutdown.tapPromise("DiagnosticsPlugin", stop); - compiler.hooks.watchClose.tap("DiagnosticsPlugin", () => { - stop(); - }); - } + 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; - return worker; + await release(compiler, key); + }, + }; } /** @@ -147,8 +169,30 @@ 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); + } + + // Two checks that would build the same program share the worker holding it. + const program = `typescript-worker\0${sharingKey("typescript", options) || id}`; const worker = canThread(options) - ? getWorker(compilation.compiler, id, options) + ? acquire( + compilation.compiler, + program, + () => + createSolo(nodeRequire.resolve("./typescript-worker.js"), [options]), + (started) => started.end(), + ) : null; /** @type {TypeScript} */ @@ -184,9 +228,8 @@ async function create({ key, id: entry, options, compilation }) { const { compiler } = compilation; /** @type {Map} */ - const runs = runsByCompiler.get(compiler) || new Map(); - - runsByCompiler.set(compiler, runs); + /** @type {Map} */ + const runs = keptFor(compiler, "typescript-runs", () => new Map()); /** * @param {Omit} found what this run found and read @@ -241,6 +284,7 @@ async function create({ key, id: entry, options, compilation }) { directories: found.directories, writes: found.writes, missing: found.missing, + extensions: found.extensions, }, found.seen, ); @@ -256,6 +300,7 @@ async function create({ key, id: entry, options, compilation }) { directories: found.directories, writes: found.writes, missing: [...held.missing], + extensions: found.extensions, }, [...held.seen], ); @@ -322,9 +367,7 @@ async function create({ key, id: entry, options, compilation }) { if (!worker || compiler.watchMode) return; - dropWorker(compilation.compiler, id); - - await worker.end(); + await release(compilation.compiler, program); }, }; } diff --git a/src/compiler-state.js b/src/compiler-state.js new file mode 100644 index 00000000..36c0e9c6 --- /dev/null +++ b/src/compiler-state.js @@ -0,0 +1,121 @@ +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** + * What lives as long as a compiler does rather than as long as one of its + * builds — a check's results, the program it keeps, the graph it reads — and + * the workers and processes compilers share, in one place, so that every check + * keeps and lets go of them the same way. + */ + +/** @type {WeakMap>} */ +const keptByCompiler = new WeakMap(); + +/** + * What a compiler keeps under a key, made the first time it is asked for. It + * goes when the compiler does. + * @template T + * @param {EXPECTED_ANY} compiler the compiler it lives as long as + * @param {string} key what tells it from the compiler's other state + * @param {() => T} create makes it the first time + * @returns {T} what the compiler keeps under the key + */ +function keptFor(compiler, key, create) { + let kept = keptByCompiler.get(compiler); + + if (!kept) { + kept = new Map(); + keptByCompiler.set(compiler, kept); + } + + if (!kept.has(key)) kept.set(key, create()); + + return kept.get(key); +} + +/** + * @typedef {object} Shared + * @property {EXPECTED_ANY} value what was started + * @property {(value: EXPECTED_ANY) => Promise | void} stop stops it + * @property {Set} users the compilers using it + */ + +/** @type {Map} */ +const shared = new Map(); + +/** @type {WeakMap>} */ +const heldByCompiler = new WeakMap(); + +/** + * Lets go of what a compiler acquired under a key, and stops it once no + * compiler uses it any more. + * @param {EXPECTED_ANY} compiler the compiler letting go + * @param {string} key what it was acquired under + * @returns {Promise} once it is stopped, if it was the last user + */ +async function release(compiler, key) { + const held = heldByCompiler.get(compiler); + + if (held) held.delete(key); + + const one = shared.get(key); + + if (!one || !one.users.delete(compiler) || one.users.size > 0) return; + + shared.delete(key); + + await one.stop(one.value); +} + +/** + * A worker or a process started once for every compiler asking under the same + * key — the compilers of a `MultiCompiler` checking the same project share it + * — and stopped when the last of them shuts down or closes its watch: in a + * watch it lives as long as the compiler rather than as long as a build, and a + * thread or a process left running would hold webpack's open. + * @template T + * @param {EXPECTED_ANY} compiler the compiler using it + * @param {string} key what tells it from what other checks start + * @param {() => T | null} start starts it, or answers nothing when it cannot be + * @param {(value: T) => Promise | void} stop stops it + * @returns {T | null} what was started, or nothing + */ +function acquire(compiler, key, start, stop) { + let one = shared.get(key); + + if (!one) { + const value = start(); + + if (value === null) return null; + + one = { value, stop, users: new Set() }; + shared.set(key, one); + } + + one.users.add(compiler); + + let held = heldByCompiler.get(compiler); + + if (!held) { + held = new Set(); + heldByCompiler.set(compiler, held); + + const releaseAll = () => + Promise.all( + [.../** @type {Set} */ (held)].map((key) => + release(compiler, key), + ), + ).then(() => {}); + + compiler.hooks.shutdown.tapPromise("DiagnosticsPlugin", releaseAll); + compiler.hooks.watchClose.tap("DiagnosticsPlugin", () => { + releaseAll(); + }); + } + + held.add(key); + + return one.value; +} + +export { acquire, keptFor, release }; diff --git a/src/import-graph.js b/src/import-graph.js index f5419522..ffac51bc 100644 --- a/src/import-graph.js +++ b/src/import-graph.js @@ -4,9 +4,10 @@ import { createHash } from "node:crypto"; import { statSync } from "node:fs"; import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; +import { dirname, extname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { keptFor } from "./compiler-state.js"; import { toPosixPath } from "./utils.js"; /** @typedef {EXPECTED_ANY} TypeScript */ @@ -41,15 +42,51 @@ import { toPosixPath } from "./utils.js"; * @property {Map} configHashes each config file and what it extends, hashed, as the signatures were last read under * @property {Map} resolutions what each set of compiler options resolved this pass, so that every file in a folder does not look its imports up again * @property {Map} faces what each file that changed showed the files importing it, as the last change left it, hashed + * @property {Set | undefined} scripts what a file TypeScript reads ends in, read from it once + * @property {EXPECTED_ANY} fallback the options of a directory no config file covers */ -// What a file webpack bundles and TypeScript has no types for ends in. -const ASSET = - /\.(?:css|scss|sass|less|styl|stylus|pcss|postcss|svg|png|jpe?g|gif|webp|avif|ico|bmp|tiff?|woff2?|ttf|otf|eot|txt|md|html?|wasm|mp4|webm|mp3|wav|ogg|flac|aac)(?:\?.*)?$/iu; +/** + * What the options of a directory no config file covers are taken as: what a + * bundler resolves, JavaScript included. One object, which is what the + * resolutions of a pass are kept against. + * @param {Graph} graph the graph + * @returns {EXPECTED_ANY} the options + */ +function fallbackOptions(graph) { + if (!graph.fallback) { + graph.fallback = { + allowJs: true, + moduleResolution: graph.ts.ModuleResolutionKind.Bundler, + }; + } -// What the options of a directory no config file covers are taken as: what a -// bundler resolves, JavaScript included. -const FALLBACK_OPTIONS = { allowJs: true, moduleResolution: 100 }; + return graph.fallback; +} + +/** + * Whether an import names a file TypeScript has no types for — a stylesheet, + * an image — which webpack bundles and no file written later turns into a + * type. What counts as a script is TypeScript's to say, so a kind of asset + * no one listed is still one. + * @param {Graph} graph the graph + * @param {string} fileName what the import names + * @returns {boolean} whether it names an asset + */ +function isAsset(graph, fileName) { + const extension = extname(fileName.split("?")[0]).toLowerCase(); + + if (extension === "") return false; + + if (!graph.scripts) { + graph.scripts = new Set([ + ...graph.ts.getSupportedExtensions({ allowJs: true }).flat(), + graph.ts.Extension.Json, + ]); + } + + return !graph.scripts.has(extension); +} /** * @param {TypeScript} ts the TypeScript the imports are resolved with @@ -65,6 +102,8 @@ function createGraph(ts) { configHashes: new Map(), resolutions: new Map(), faces: new Map(), + scripts: undefined, + fallback: undefined, }; } @@ -88,7 +127,7 @@ function optionsFor(graph, file) { const config = graph.configs.get(directory); - if (!config) return FALLBACK_OPTIONS; + if (!config) return fallbackOptions(graph); if (!graph.options.has(config)) { const parsed = ts.getParsedCommandLineOfConfigFile( @@ -97,7 +136,7 @@ function optionsFor(graph, file) { { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} }, ); - graph.options.set(config, parsed ? parsed.options : FALLBACK_OPTIONS); + graph.options.set(config, parsed ? parsed.options : fallbackOptions(graph)); } return graph.options.get(config); @@ -163,7 +202,7 @@ function importsOf(graph, file, text) { resolved.push(toPosixPath(resolve(found.resolvedFileName))); } else if (!fileName.startsWith(".")) { continue; - } else if (ASSET.test(fileName) && !options.allowArbitraryExtensions) { + } else if (isAsset(graph, fileName) && !options.allowArbitraryExtensions) { // A stylesheet or an image is a module to webpack and nothing to // TypeScript, which no file written later turns into a type — unless // `allowArbitraryExtensions` reads one through a `.d.css.ts` beside it. @@ -381,14 +420,6 @@ function affectedFiles(graph, files, changed) { return new Set([...reached].filter((file) => covered.has(file))); } -/** - * The graphs a compiler keeps, one per check. A graph is changed by every - * question asked of it — what changed is dropped from it, and a changed config - * empties it — so two checks sharing one would each see the other's answer. - * @type {WeakMap>>} - */ -const graphsByCompiler = new WeakMap(); - /** * The graph a compiler keeps for one check, read with the TypeScript the * check's project installs — the one its own tooling resolves imports with. @@ -398,37 +429,33 @@ const graphsByCompiler = new WeakMap(); * @returns {Promise} the graph, or nothing without a TypeScript to read it with */ function getGraph(compiler, context, owner) { - let graphs = graphsByCompiler.get(compiler); - - if (!graphs) { - graphs = new Map(); - graphsByCompiler.set(compiler, graphs); - } - - const key = `${owner}\0${context}`; - let graph = graphs.get(key); - - if (!graph) { - graph = (async () => { - try { - const loaded = await import( - pathToFileURL( - createRequire(join(context, "package.json")).resolve("typescript"), - ).href - ); - - return createGraph(loaded.default || loaded); - } catch { - // Without a TypeScript of the project's own there is nothing to say - // which file reads which, and every file is linted again instead. + return keptFor(compiler, `graph\0${owner}\0${context}`, async () => { + try { + const loaded = await import( + pathToFileURL( + createRequire(join(context, "package.json")).resolve("typescript"), + ).href + ); + + const ts = loaded.default || loaded; + + // TypeScript 7 is a native compiler whose package exports no such + // API: what a file imports cannot be read through it. + if ( + typeof ts.preProcessFile !== "function" || + typeof ts.resolveModuleName !== "function" || + !ts.sys + ) { return undefined; } - })(); - graphs.set(key, graph); - } - - return graph; + return createGraph(ts); + } catch { + // Without a TypeScript of the project's own there is nothing to say + // which file reads which, and every file is linted again instead. + return undefined; + } + }); } /** diff --git a/test/compiler-state.test.js b/test/compiler-state.test.js new file mode 100644 index 00000000..2fc7799c --- /dev/null +++ b/test/compiler-state.test.js @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { acquire, keptFor, release } from "../src/compiler-state.js"; + +/** + * @returns {EXPECTED_ANY} a compiler with the hooks the state taps + */ +function fakeCompiler() { + /** @type {(() => Promise)[]} */ + const shutdown = []; + /** @type {(() => void)[]} */ + const watchClose = []; + + return { + hooks: { + shutdown: { tapPromise: (_, fn) => shutdown.push(fn) }, + watchClose: { tap: (_, fn) => watchClose.push(fn) }, + }, + shutdown: () => Promise.all(shutdown.map((fn) => fn())), + closeWatch: () => { + for (const fn of watchClose) fn(); + }, + tapped: () => shutdown.length + watchClose.length, + }; +} + +describe("compiler state", () => { + it("should keep one thing per compiler and key", () => { + const one = fakeCompiler(); + const other = fakeCompiler(); + const made = keptFor(one, "results", () => new Map()); + + assert.strictEqual( + keptFor(one, "results", () => new Map()), + made, + ); + assert.notStrictEqual( + keptFor(other, "results", () => new Map()), + made, + ); + assert.notStrictEqual( + keptFor(one, "graph", () => new Map()), + made, + ); + }); + + it("should share what compilers acquire under one key, and stop it with the last", async () => { + const one = fakeCompiler(); + const other = fakeCompiler(); + const stopped = []; + let started = 0; + const start = () => ({ id: (started += 1) }); + const stop = (value) => { + stopped.push(value.id); + }; + + const first = acquire(one, "worker", start, stop); + + assert.strictEqual(acquire(other, "worker", start, stop), first); + assert.strictEqual(started, 1); + + await one.shutdown(); + assert.deepStrictEqual(stopped, []); + + other.closeWatch(); + await new Promise((resolve) => { + setImmediate(resolve); + }); + assert.deepStrictEqual(stopped, [1]); + + // Gone with its last user, so the next compiler starts a new one. + assert.deepStrictEqual(acquire(one, "worker", start, stop), { id: 2 }); + await release(one, "worker"); + assert.deepStrictEqual(stopped, [1, 2]); + }); + + it("should tap a compiler once however much it acquires", () => { + const compiler = fakeCompiler(); + + acquire( + compiler, + "a", + () => ({}), + () => {}, + ); + acquire( + compiler, + "b", + () => ({}), + () => {}, + ); + acquire( + compiler, + "a", + () => ({}), + () => {}, + ); + + assert.strictEqual(compiler.tapped(), 2); + }); + + it("should hand back nothing and keep nothing when it cannot start", () => { + const compiler = fakeCompiler(); + + assert.strictEqual( + acquire( + compiler, + "none", + () => null, + () => {}, + ), + null, + ); + assert.deepStrictEqual( + acquire( + compiler, + "none", + () => ({ started: true }), + () => {}, + ), + { started: true }, + ); + }); + + it("should do nothing for what a compiler never acquired", async () => { + await release(fakeCompiler(), "nothing"); + }); +}); diff --git a/test/import-graph.test.js b/test/import-graph.test.js index 50a5957f..042eeb8a 100644 --- a/test/import-graph.test.js +++ b/test/import-graph.test.js @@ -212,6 +212,15 @@ describe("import graph", () => { }); }); + it("should resolve as a bundler does where no config file covers a directory", () => { + rmSync(at("tsconfig.json")); + + assert.deepStrictEqual( + affected(["top.ts", "middle.ts", "alone.ts"], ["leaf.ts"]), + ["middle.ts", "top.ts"], + ); + }); + it("should follow a type-only import, which a bundler drops", () => { assert.deepStrictEqual(affected(all, ["types.ts"]), [ "typed.ts", @@ -281,7 +290,9 @@ describe("import graph", () => { // written later turns it into a type. write( "alone.ts", - 'import "./styles.css";\nimport logo from "./logo.svg?url";\nexport const a = logo;\n', + // A kind of asset no list would name is one too: what TypeScript + // reads is TypeScript's to say. + 'import "./styles.css";\nimport logo from "./logo.svg?url";\nimport data from "./data.toml";\nexport const a = logo + data;\n', ); assert.deepStrictEqual(affected(all, ["leaf.ts"]), [ @@ -369,6 +380,22 @@ describe("the graph a compiler keeps", () => { assert.strictEqual(await getGraph({}, project), undefined); }); + + it("should be nothing for a project whose TypeScript has no API to read imports with", async (t) => { + // As TypeScript 7 is: a native compiler whose package exports a version. + const project = mkdtempSync(join(tmpdir(), "native-typescript-")); + const native = join(project, "node_modules", "typescript"); + + t.after(() => rmSync(project, { recursive: true, force: true })); + mkdirSync(native, { recursive: true }); + writeFileSync(join(native, "package.json"), '{ "main": "index.js" }'); + writeFileSync( + join(native, "index.js"), + 'module.exports = { version: "7.0.0", versionMajorMinor: "7.0" };\n', + ); + + assert.strictEqual(await getGraph({}, project), undefined); + }); }); describe("signatures", () => { diff --git a/test/lints-once.test.js b/test/lints-once.test.js index cd09c1eb..a18b8bd6 100644 --- a/test/lints-once.test.js +++ b/test/lints-once.test.js @@ -9,7 +9,9 @@ import { afterEach, describe, it } from "node:test"; import webpack from "webpack"; +import biome from "../src/checks/biome.js"; import eslint from "../src/checks/eslint.js"; +import oxlint from "../src/checks/oxlint.js"; import DiagnosticsPlugin from "../src/index.js"; import { countThreads } from "../src/threads.js"; @@ -50,6 +52,11 @@ describe("linting once", () => { }); }); + it("should hand Biome and oxlint every file at once, each run being a process", () => { + assert.strictEqual(biome.lintsOnce && biome.lintsOnce(), true); + assert.strictEqual(oxlint.lintsOnce && oxlint.lintsOnce(), true); + }); + describe("a check that lints once", () => { /** @type {string | undefined} */ let project; 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/mock/typescript-counter/index.js b/test/typescript/mock/typescript-counter/index.js new file mode 100644 index 00000000..55fadac5 --- /dev/null +++ b/test/typescript/mock/typescript-counter/index.js @@ -0,0 +1,31 @@ +// Real TypeScript, writing down whether each program was built from the very +// one this module built last, wherever the check runs. A program read back +// from `.tsbuildinfo` is an old program too, but not one kept in memory. +const { appendFileSync } = require("node:fs"); + +const typescript = require("typescript"); + +let last; + +const counting = + (build) => + (rootNames, options, host, old, ...rest) => { + appendFileSync( + process.env.TYPESCRIPT_COUNTER_FILE, + old !== undefined && old === last ? "reused\n" : "fresh\n", + ); + + last = build(rootNames, options, host, old, ...rest); + + return last; + }; + +module.exports = { + ...typescript, + createSemanticDiagnosticsBuilderProgram: counting( + typescript.createSemanticDiagnosticsBuilderProgram, + ), + createEmitAndSemanticDiagnosticsBuilderProgram: counting( + typescript.createEmitAndSemanticDiagnosticsBuilderProgram, + ), +}; diff --git a/test/typescript/mock/typescript-version-only/index.js b/test/typescript/mock/typescript-version-only/index.js new file mode 100644 index 00000000..7851c5a0 --- /dev/null +++ b/test/typescript/mock/typescript-version-only/index.js @@ -0,0 +1,2 @@ +// What TypeScript 7.0 exports from its main entry: its version, and no API. +module.exports = { version: "7.0.2" }; diff --git a/test/typescript/native.test.js b/test/typescript/native.test.js new file mode 100644 index 00000000..87fd8043 --- /dev/null +++ b/test/typescript/native.test.js @@ -0,0 +1,291 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, it } from "node:test"; + +import { + checkNative, + loadNative, + startNative, + stopNative, +} 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"); +// Outside anything webpack watches for the build itself. +const base = join( + mkdtempSync(join(tmpdir(), "typescript-7-base-")), + "tsconfig.json", +); + +describe("typescript 7", () => { + let watch; + + afterEach(() => { + if (watch) watch.close(); + + rmSync(checked, { force: true }); + rmSync(configFile, { force: true }); + rmSync(trigger, { force: true }); + rmSync(base, { 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 give compilers sharing the process one run at a time", async () => { + const context = join(import.meta.dirname, "fixtures", "good"); + const options = { context }; + const native = startNative(await loadNative(typescriptPath), options); + /** @type {string[]} */ + const order = []; + /** + * @param {(change: unknown) => Promise} take what takes a snapshot + * @returns {(change: unknown) => Promise} it, writing down when + */ + const noted = (take) => async (change) => { + order.push("start"); + + const taken = await take(change); + + order.push("taken"); + taken.update = noted(taken.update.bind(taken)); + + return taken; + }; + + native.api.createSnapshot = noted( + native.api.createSnapshot.bind(native.api), + ); + + try { + const [first, second] = await Promise.all([ + checkNative(native, options, {}), + checkNative(native, options, { modifiedFiles: new Set() }), + ]); + + assert.deepStrictEqual(first.diagnostics, []); + assert.deepStrictEqual(second.diagnostics, []); + // The second run takes its snapshot once the first is done with its own. + assert.deepStrictEqual(order, ["start", "taken", "start", "taken"]); + } finally { + await stopNative(native); + } + }); + + 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 say a TypeScript with no API to check with has none", async () => { + for (const threads of [false, "auto"]) { + const stats = await pack("good", { + typescriptPath: join( + import.meta.dirname, + "mock/typescript-version-only", + ), + threads, + }).runAsync(); + + assert.match( + stats.compilation.errors[0].message, + /TypeScript 7\.0\.2 offers no compiler API to check with/u, + ); + } + }); + + it("should say there is no config file to read", async () => { + // Outside this repository, whose own config file the search would find. + const context = mkdtempSync(join(tmpdir(), "typescript-7-")); + + writeFileSync(join(context, "index.js"), "export default 1;\n"); + writeFileSync(join(context, "checked.ts"), "export const fine = 1;\n"); + + const stats = await pack( + "good", + { typescriptPath }, + { context, output: { path: join(context, "dist") } }, + ).runAsync(); + + assert.match( + stats.compilation.errors[0].message, + /no 'tsconfig\.json' was found above/u, + ); + }); + + 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(); + }); + }); + + it( + "should check again once a config file it extends changes", + { timeout: 20000 }, + (t, done) => { + writeFileSync( + base, + `${JSON.stringify({ compilerOptions: { noImplicitAny: false } })}\n`, + ); + writeFileSync( + configFile, + `${JSON.stringify({ + extends: base, + compilerOptions: { skipLibCheck: true }, + include: ["*.ts"], + })}\n`, + ); + writeFileSync(trigger, "export const trigger = 1;\n"); + // Fine until `noImplicitAny`, which TypeScript 7 turns on by default, is. + writeFileSync( + checked, + "export function same(value) {\n return value;\n}\n", + ); + + let strict = false; + + // Webpack reads a config file and what it extends to resolve with, and + // watches them for that; turned off, only the check can watch them. + watch = pack( + "native-watch", + { typescriptPath }, + { resolve: { tsconfig: false } }, + ).watch({}, (err, stats) => { + assert.strictEqual(err, null); + + if (!strict) { + if (stats.hasErrors()) return; + + strict = true; + // Once the rebuilds webpack starts for what was written before it + // watched are over, so that only the watch on this file can start one. + setTimeout(() => { + writeFileSync( + base, + `${JSON.stringify({ compilerOptions: { noImplicitAny: true } })}\n`, + ); + }, 1000); + + return; + } + + if (!stats.hasErrors()) return; + + assert.match(stats.compilation.errors[0].message, /TS7006/u); + done(); + }); + }, + ); +}); diff --git a/test/typescript/share.test.js b/test/typescript/share.test.js new file mode 100644 index 00000000..a02e1a20 --- /dev/null +++ b/test/typescript/share.test.js @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; + +import webpack from "webpack"; + +import { countThreads } from "../../src/threads.js"; + +import conf from "./utils/conf.js"; + +const typescriptPath = join(import.meta.dirname, "mock/typescript-counter"); + +describe("share", () => { + // Only a worker outlives the compilation that started it, which is what a + // second compiler can find the program on. + it( + "should build one program for the compilers checking the same project", + { skip: countThreads("auto") < 2 }, + (t, done) => { + const counter = join( + mkdtempSync(join(tmpdir(), "typescript-counter-")), + "programs", + ); + + // Read by the counting TypeScript, on the worker the check runs on. + process.env.TYPESCRIPT_COUNTER_FILE = counter; + + const configs = ["one", "two"].map((name) => { + // The same options, state file included, as two compilers given the + // same check have. + const config = conf("good", { + threads: "auto", + typescriptPath, + cacheLocation: join(dirname(counter), "typescript"), + }); + + return { + ...config, + name, + output: { path: join(config.output.path, name) }, + }; + }); + + // One after the other, so that the second does not join the first's run. + configs.parallelism = 1; + + const watch = webpack(configs).watch({}, (err) => { + assert.strictEqual(err, null); + + watch.close(() => { + assert.deepStrictEqual( + readFileSync(counter, "utf8").trim().split("\n"), + ["fresh", "reused"], + ); + done(); + }); + }); + }, + ); +}); diff --git a/types/check.d.ts b/types/check.d.ts index ea7125ad..9d18b0b2 100644 --- a/types/check.d.ts +++ b/types/check.d.ts @@ -1,5 +1,4 @@ export default createCheckRunner; -export type EXPECTED_ANY = any; export type Compilation = import("webpack").Compilation; export type CheckResult = import("./checks/index.js").CheckResult; export type CheckInstance = import("./checks/index.js").CheckInstance; @@ -33,6 +32,18 @@ export type Runner = { detach: (report: Promise) => void; }; export type ResultStore = Map; +export type EXPECTED_ANY = any; +/** + * What tells one check's run of a tool from another's, so that only two runs + * that would do the same work are shared. + * @param {string} name the name of the check + * @param {EnabledCheck["options"]} options the options it runs under + * @returns {string | undefined} the key, or nothing when they cannot be compared + */ +export function sharingKey( + name: string, + options: EnabledCheck["options"], +): string | undefined; /** * Creates the runner synchronously so that the compilation hooks are tapped * before webpack starts building modules, whatever the tool takes to load. A diff --git a/types/checks/biome.d.ts b/types/checks/biome.d.ts index 64dc9c39..84812d36 100644 --- a/types/checks/biome.d.ts +++ b/types/checks/biome.d.ts @@ -9,6 +9,7 @@ declare namespace _default { export function defaultExclude(): string; export { create }; export function resultPath(result: EXPECTED_ANY): string; + export function lintsOnce(): boolean; export function readsAcrossFiles(): boolean; } export default _default; diff --git a/types/checks/oxlint.d.ts b/types/checks/oxlint.d.ts index ea07726d..e303aca0 100644 --- a/types/checks/oxlint.d.ts +++ b/types/checks/oxlint.d.ts @@ -9,6 +9,7 @@ declare namespace _default { export function defaultExclude(): string; export { create }; export function resultPath(result: EXPECTED_ANY): string; + export function lintsOnce(): boolean; export { readsAcrossFiles }; } export default _default; diff --git a/types/checks/typescript-native.d.ts b/types/checks/typescript-native.d.ts new file mode 100644 index 00000000..f7b40245 --- /dev/null +++ b/types/checks/typescript-native.d.ts @@ -0,0 +1,69 @@ +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; + 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 = { + loaded: EXPECTED_ANY; + api: EXPECTED_ANY; + snapshot: EXPECTED_ANY; + files: Set; + turn: Promise; +}; +/** + * 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 a TypeScript with a native compiler ships in place of a compiler + * API in JavaScript, or nothing for a TypeScript checked in-process. Told by + * what the package exports rather than by its version. + * @param {string} specifier where TypeScript is found + * @returns {Promise} the native API, if the package ships one + */ +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-program.d.ts b/types/checks/typescript-program.d.ts index 0f2bbe4f..396dddce 100644 --- a/types/checks/typescript-program.d.ts +++ b/types/checks/typescript-program.d.ts @@ -14,6 +14,7 @@ export type Found = { files: string[]; directories: string[]; writes: string[]; + extensions: string[]; }; export type Held = { signature: string; diff --git a/types/checks/typescript.d.ts b/types/checks/typescript.d.ts index 538fc9c2..8522fede 100644 --- a/types/checks/typescript.d.ts +++ b/types/checks/typescript.d.ts @@ -19,6 +19,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; /** @@ -67,6 +68,10 @@ export type Run = { * where a file the program does not hold yet would be found, as posix paths ending in a slash */ folders: string[]; + /** + * what a file the config file picks up ends in + */ + extensions: string[]; }; /** * @param {CheckContext} context check context diff --git a/types/compiler-state.d.ts b/types/compiler-state.d.ts new file mode 100644 index 00000000..78caa335 --- /dev/null +++ b/types/compiler-state.d.ts @@ -0,0 +1,56 @@ +export type EXPECTED_ANY = any; +export type Shared = { + /** + * what was started + */ + value: EXPECTED_ANY; + /** + * stops it + */ + stop: (value: EXPECTED_ANY) => Promise | void; + /** + * the compilers using it + */ + users: Set; +}; +/** + * A worker or a process started once for every compiler asking under the same + * key — the compilers of a `MultiCompiler` checking the same project share it + * — and stopped when the last of them shuts down or closes its watch: in a + * watch it lives as long as the compiler rather than as long as a build, and a + * thread or a process left running would hold webpack's open. + * @template T + * @param {EXPECTED_ANY} compiler the compiler using it + * @param {string} key what tells it from what other checks start + * @param {() => T | null} start starts it, or answers nothing when it cannot be + * @param {(value: T) => Promise | void} stop stops it + * @returns {T | null} what was started, or nothing + */ +export function acquire( + compiler: EXPECTED_ANY, + key: string, + start: () => T | null, + stop: (value: T) => Promise | void, +): T | null; +/** + * What a compiler keeps under a key, made the first time it is asked for. It + * goes when the compiler does. + * @template T + * @param {EXPECTED_ANY} compiler the compiler it lives as long as + * @param {string} key what tells it from the compiler's other state + * @param {() => T} create makes it the first time + * @returns {T} what the compiler keeps under the key + */ +export function keptFor( + compiler: EXPECTED_ANY, + key: string, + create: () => T, +): T; +/** + * Lets go of what a compiler acquired under a key, and stops it once no + * compiler uses it any more. + * @param {EXPECTED_ANY} compiler the compiler letting go + * @param {string} key what it was acquired under + * @returns {Promise} once it is stopped, if it was the last user + */ +export function release(compiler: EXPECTED_ANY, key: string): Promise; diff --git a/types/import-graph.d.ts b/types/import-graph.d.ts index 44894534..73593626 100644 --- a/types/import-graph.d.ts +++ b/types/import-graph.d.ts @@ -1,4 +1,3 @@ -export type EXPECTED_ANY = any; export type TypeScript = EXPECTED_ANY; /** * What one file imports, as TypeScript resolves it. @@ -79,7 +78,16 @@ export type Graph = { * what each file that changed showed the files importing it, as the last change left it, hashed */ faces: Map; + /** + * what a file TypeScript reads ends in, read from it once + */ + scripts: Set | undefined; + /** + * the options of a directory no config file covers + */ + fallback: EXPECTED_ANY; }; +export type EXPECTED_ANY = any; /** * Which files a change can have changed what typed linting says of: the ones * that changed, and every file importing one of them, however indirectly. A