From 18c74aa68b042724b923b870e04ff3289972f0c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:08:45 +0000 Subject: [PATCH 1/6] perf: watch rebuilds, TypeScript 7, and shared or stopped TypeScript runs One change in place of #371, #384, #387, #389, #390 and #391. Core: - One place keeps what lives as long as a compiler (src/compiler-state.js): keptFor() for state kept per compiler (results, detached reports, the import graph, the TypeScript program), and acquire()/release() for the workers and processes compilers share, started once per key and stopped when the last compiler using them shuts down or closes its watch. The TypeScript check's worker and TypeScript 7's process both go through it, in place of a lifecycle of their own each. - lintDependentsAfterBuild (opt-in): a watch rebuild of a check that reads across files lints only what webpack rebuilt, and what the change reaches after the build. - A check can stop a run of an older compilation nothing will report (cancel()); one another compiler joined is never stopped. - Biome and oxlint are handed every file at once rather than in batches. TypeScript: - TypeScript 7 runs through the API it ships, in a process of its own kept open while webpack watches; the import graph answers without a graph there. - A stale run stops between files; compilers checking the same project alike share one worker and one program. Nothing is listed by hand where the tool can say it: assets and the scripts a config file picks up are told apart by the extensions TypeScript reads, the fallback resolution mode and the error category come from the loaded TypeScript, and each run carries its own stop flag. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01App18sYbe6Vf6HDCjCXbvf --- .changeset/assets-named-by-typescript.md | 5 + .changeset/cli-checks-one-run.md | 5 + .changeset/import-graph-typescript-7.md | 5 + .changeset/lint-dependents-after-build.md | 5 + .../ts-share-program-across-compilers.md | 5 + .changeset/ts-stop-stale-runs.md | 5 + .changeset/typescript-7.md | 5 + .cspell.json | 4 +- README.md | 77 +++- package-lock.json | 381 +++++++++++++++++- package.json | 7 +- src/check.js | 94 +++-- src/checks/biome.check.js | 2 +- src/checks/biome.js | 4 + src/checks/eslint.check.js | 2 +- src/checks/index.js | 1 + src/checks/oxlint.check.js | 2 +- src/checks/oxlint.js | 4 + src/checks/stylelint.check.js | 2 +- src/checks/typescript-native.js | 313 ++++++++++++++ src/checks/typescript-program.js | 177 ++++---- src/checks/typescript-worker.js | 16 +- src/checks/typescript.check.js | 2 +- src/checks/typescript.js | 178 +++++--- src/compiler-state.js | 121 ++++++ src/import-graph.js | 119 +++--- src/index.js | 128 +++++- src/options.check.js | 2 +- src/options.js | 1 + src/options.json | 3 + src/shared-options.json | 4 + src/threads.js | 30 +- test/after-build.test.js | 200 +++++++++ test/compiler-state.test.js | 129 ++++++ test/import-graph.test.js | 20 +- test/incremental.test.js | 120 ++++++ test/lints-once.test.js | 7 + test/store.test.js | 100 +++++ test/typescript/cancel.test.js | 149 +++++++ test/typescript/fixtures/cancel/index.js | 1 + .../typescript/fixtures/native-watch/index.js | 1 + .../mock/typescript-counter/index.js | 31 ++ .../typescript/mock/typescript-stall/index.js | 62 +++ test/typescript/native.test.js | 145 +++++++ test/typescript/share.test.js | 62 +++ types/check.d.ts | 18 +- types/checks/biome.d.ts | 1 + types/checks/index.d.ts | 5 + types/checks/oxlint.d.ts | 1 + types/checks/stylelint.check.d.ts | 4 +- types/checks/typescript-native.d.ts | 69 ++++ types/checks/typescript-program.d.ts | 10 +- types/checks/typescript-worker.d.ts | 7 +- types/checks/typescript.d.ts | 5 + types/compiler-state.d.ts | 56 +++ types/import-graph.d.ts | 10 +- types/options.d.ts | 4 + types/threads.d.ts | 3 +- 58 files changed, 2667 insertions(+), 262 deletions(-) create mode 100644 .changeset/assets-named-by-typescript.md create mode 100644 .changeset/cli-checks-one-run.md create mode 100644 .changeset/import-graph-typescript-7.md create mode 100644 .changeset/lint-dependents-after-build.md create mode 100644 .changeset/ts-share-program-across-compilers.md create mode 100644 .changeset/ts-stop-stale-runs.md create mode 100644 .changeset/typescript-7.md create mode 100644 src/checks/typescript-native.js create mode 100644 src/compiler-state.js create mode 100644 test/after-build.test.js create mode 100644 test/compiler-state.test.js create mode 100644 test/typescript/cancel.test.js create mode 100644 test/typescript/fixtures/cancel/index.js create mode 100644 test/typescript/fixtures/native-watch/index.js create mode 100644 test/typescript/mock/typescript-counter/index.js create mode 100644 test/typescript/mock/typescript-stall/index.js create mode 100644 test/typescript/native.test.js create mode 100644 test/typescript/share.test.js create mode 100644 types/checks/typescript-native.d.ts create mode 100644 types/compiler-state.d.ts 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/lint-dependents-after-build.md b/.changeset/lint-dependents-after-build.md new file mode 100644 index 00000000..0c6b17b2 --- /dev/null +++ b/.changeset/lint-dependents-after-build.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": minor +--- + +Add `lintDependentsAfterBuild`: a watch rebuild under typed linting lints what it rebuilt with the build and the rest once the build is over, printing what changes and carrying it into the next build. diff --git a/.changeset/ts-share-program-across-compilers.md b/.changeset/ts-share-program-across-compilers.md new file mode 100644 index 00000000..78a11acf --- /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. A run one compiler stops no longer stops another's on the shared worker. Under TypeScript 7 the native compiler's process is shared the same way. diff --git a/.changeset/ts-stop-stale-runs.md b/.changeset/ts-stop-stale-runs.md new file mode 100644 index 00000000..6361e5b2 --- /dev/null +++ b/.changeset/ts-stop-stale-runs.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": minor +--- + +Stop a TypeScript run that nothing will report any more. A check that `reportAs` keeps off the compilation runs after a watch rebuild. When it is on a worker of its own, it now stops a run of an older rebuild as soon as the next one starts, instead of finishing a check whose report would be dropped. Over 600 files, an edit made during a long check was reported after 2.3–2.6 s instead of 4.2–4.5 s, with about 2–3 CPU-seconds less work. Check adapters can stop such a run too, through an optional `cancel()` on their instance. 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/.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..a3032ce7 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Every check to run is an entry in `checks`, named by its `use`. The list may nam | [`outputReport`](#outputreport) | Shared | A file the same results are written to. | | [`readsAcrossFiles`](#readsacrossfiles) | Shared | Whether a file's results can turn on another, so a rebuild lints importers. | | [`reportAs`](#reportas) | Shared | What the build carries: an error, a warning, a log line, or nothing. | +| [`lintDependentsAfterBuild`](#lintdependentsafterbuild) | Shared | Whether a watch rebuild waits for the files that turn on a change. | | [`resourceQueryExclude`](#resourcequeryexclude) | Shared | Which module queries are left out. | | [`threads`](#threads) | Shared | How wide the work is spread. | | [`configType`, `eslintPath`](#eslint) | Check | ESLint's own. | @@ -344,7 +345,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 @@ -360,6 +366,37 @@ and a check configured with a function, such as a formatter written in the configuration, is run on webpack's thread, because a function cannot be handed to a worker. +#### `lintDependentsAfterBuild` + +- Type: + +```ts +type lintDependentsAfterBuild = boolean; +``` + +- Default: `false` + +Whether a watch rebuild waits for the files it did not rebuild but has to lint +again — for a check that [reads across files](#readsacrossfiles), the ones +importing a file that changed, however indirectly. Set, the build lints what it rebuilt and reports those from +their last run, and they are linted again once the build is over. When that +changes what the check reports, the new report is printed to the terminal, and +the next build carries it — so a dev server overlays it from then on rather than +at once. `fork-ts-checker-webpack-plugin`'s `async` makes the same trade. + +```js +new DiagnosticsPlugin({ + checks: [{ use: "eslint", lintDependentsAfterBuild: true }], +}); +``` + +Over three hundred modules under typed linting, an edit's rebuild took 191 ms +rather than 2.0 s, and an error it caused in a file importing the edited one was +printed 2.4 s after the edit. A build that is not watched, the first build of a +watch, and a check writing an [`outputReport`](#outputreport) lint everything +before they finish whatever this says — that file is written with the build, and +would otherwise hold what the build reported rather than what was found after. + #### `readsAcrossFiles` - Type: @@ -383,7 +420,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 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. @@ -517,7 +555,13 @@ The work is moved out of the rebuild rather than off the machine: an edit arriving while such a check is still running waits for the rest of it, since a check holds the thread the build runs on. A report a newer rebuild has already overtaken is dropped, and a check never has two of its runs going at once — the -next starts when the last has reported. +next starts when the last has reported. A TypeScript check on a worker of its +own (see [`threads`](#threads)) stops such a run as soon as the next rebuild +starts, between one file and the next: what the program got through is kept, +and the next run checks the rest. An edit made while a long run is going is +then reported once its own run is done, without waiting for the rest of the +old one. Over 600 files, an edit made while an earlier one was still being +checked was reported after 2.3–2.6 s instead of 4.2–4.5 s. #### `ignoreDiagnostics` @@ -784,6 +828,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, one +run for each 500, 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 +892,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 +955,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/check.js b/src/check.js index be7398d5..12c4f62e 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"; @@ -23,17 +24,23 @@ import { toPosixPath } from "./utils.js"; /** @typedef {Dependencies & { errors?: DiagnosticError, warnings?: DiagnosticError, outputReport?: OutputReportContent }} Report */ /** @typedef {{ lint: (files: string[]) => void, keep: (files: string[]) => void, report: () => Promise, detach: (report: Promise) => void }} Runner */ /** @typedef {Map} ResultStore */ +/** @typedef {{ key: string, run: Promise, joined: boolean }} SharedRun */ -/** @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, cancel: () => void }>} 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 -// belongs to the store of the compiler that made it. -/** @type {Map>} */ +// belongs to the store of the compiler that made it. A run another compiler +// joined is one that compiler waits for too, so it is never stopped, and a run +// that is stopped is no longer there to join. +/** @type {Map} */ const sharedRuns = new Map(); // What only decides which files are checked, or how what is found is reported. @@ -42,6 +49,7 @@ const NOT_SHARED = new Set([ "exclude", "ignoreDiagnostics", "extensions", + "lintDependentsAfterBuild", "files", "formatter", "outputReport", @@ -85,41 +93,29 @@ 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()); } /** * The report of a run left to finish on its own, taken so that the next run of * the same check waits for it rather than driving a second tool beside it. + * Nothing prints a report of an older compilation, so a check that can stop + * one is told to. * @param {Compilation} compilation compilation * @param {string} check a key unique to the check within the compiler * @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); - return report; + if (!report) return undefined; + + report.cancel(); + + return report.settled; } /** @@ -249,6 +245,9 @@ function createCheckRunner(key, { id, name, adapter, options }, compilation) { const linted = new Set(); // A check that cannot lint fails the same way for every batch it is given. let failed = false; + // The runs this one started that another compiler could join. + /** @type {SharedRun[]} */ + const runs = []; /** * What the check answers with, and what it says it read to answer — taken @@ -262,7 +261,11 @@ function createCheckRunner(key, { id, name, adapter, options }, compilation) { const key = sharing && `${sharing}\0${files.toSorted().join("\0")}`; const running = key ? sharedRuns.get(key) : undefined; - if (running) return running; + if (running) { + running.joined = true; + + return running.run; + } const started = instance.lintFiles(files).then((results) => ({ results, @@ -274,8 +277,10 @@ function createCheckRunner(key, { id, name, adapter, options }, compilation) { if (key) { const forget = () => sharedRuns.delete(key); + const shared = { key, run: started, joined: false }; - sharedRuns.set(key, started); + sharedRuns.set(key, shared); + runs.push(shared); started.then(forget, forget); } @@ -490,23 +495,30 @@ 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( - checkKey, - report.then( + detachedReportsOf(compilation).set(checkKey, { + settled: report.then( () => {}, () => {}, ), - ); + cancel: () => { + if (runs.some((shared) => shared.joined)) return; + + for (const shared of runs) { + if (sharedRuns.get(shared.key) === shared) { + sharedRuns.delete(shared.key); + } + } + + pending.then((instance) => { + if (instance && instance.cancel) instance.cancel(); + }); + }, + }); } return { detach, keep, lint, report }; } +export { sharingKey }; + export default createCheckRunner; diff --git a/src/checks/biome.check.js b/src/checks/biome.check.js index ea07a632..b6dfe438 100644 --- a/src/checks/biome.check.js +++ b/src/checks/biome.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=s;export default s;const e={type:"object",additionalProperties:!0,properties:{biomePath:{type:"string"},command:{enum:["lint","check"]},configFile:{type:"string"},args:{type:"array",items:{type:"string"}}},allOf:[{$ref:"../entry-options.json"}],$id:"biome.json"},r={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function t(e,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=e}={}){let a=null,l=0;if(l==l){if(!e||"object"!=typeof e||Array.isArray(e))return t.errors=[{params:{type:"object"}}],!1;if(void 0!==e.cache){if("boolean"!=typeof e.cache)return t.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==e.cacheLocation){if("string"!=typeof e.cacheLocation)return t.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.exclude){let r=e.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=e===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.extensions){let r=e.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=e===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.files){let r=e.files,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=e===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.fix){if("boolean"!=typeof e.fix)return t.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.formatter){let r=e.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let e=l;if(!(r instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=e===l,!(o=o||y)){let e=l;if(!(r instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=e===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.ignoreDiagnostics){let s=e.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(i=i||m)){let e=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(i=i||m)){let e=l;if(l===e)if(s&&"object"==typeof s&&!Array.isArray(s)){let e=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(e===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var g=e===l}else g=!0;if(g){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}g=e===l}else g=!0;if(g)if(void 0!==s.severity){let e=s.severity,t=l;if("error"!==e&&"warning"!==e){let e={params:{allowedValues:r.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[e]:a.push(e),l++}g=t===l}else g=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(i=i||m)){let e=l;if(l===e)if(Array.isArray(s)){let e=s.length;for(let t=0;t true, readsAcrossFiles: () => true, }; diff --git a/src/checks/eslint.check.js b/src/checks/eslint.check.js index a64cd040..e5d5a4fb 100644 --- a/src/checks/eslint.check.js +++ b/src/checks/eslint.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=t;export default t;const e={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function r(t,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=t}={}){let a=null,l=0;if(l==l){if(!t||"object"!=typeof t||Array.isArray(t))return r.errors=[{params:{type:"object"}}],!1;if(void 0!==t.cache){if("boolean"!=typeof t.cache)return r.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==t.cacheLocation){if("string"!=typeof t.cacheLocation)return r.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.exclude){let e=t.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=r===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.extensions){let e=t.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=r===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.files){let e=t.files,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=r===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.fix){if("boolean"!=typeof t.fix)return r.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.formatter){let e=t.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let r=l;if(!(e instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=r===l,!(o=o||y)){let r=l;if(!(e instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=r===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.ignoreDiagnostics){let s=t.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(i=i||m)){let r=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=r===l,!(i=i||m)){let r=l;if(l===r)if(s&&"object"==typeof s&&!Array.isArray(s)){let r=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(r===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var g=e===l}else g=!0;if(g){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}g=e===l}else g=!0;if(g)if(void 0!==s.severity){let r=s.severity,t=l;if("error"!==r&&"warning"!==r){let r={params:{allowedValues:e.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[r]:a.push(r),l++}g=t===l}else g=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=r===l,!(i=i||m)){let r=l;if(l===r)if(Array.isArray(s)){let r=s.length;for(let t=0;t boolean) => CheckResult[])=} filterResults keeps what a predicate accepts of every result, which is what `ignore` is applied through * @property {(formatter?: FormatterOption) => Promise} getFormatter loads a formatter, falling back to the tool's default one * @property {() => Promise} cleanup releases whatever the tool holds after a run + * @property {(() => void)=} cancel stops a run of an older compilation that is still going, which nothing reports any more; what it answers with is dropped */ /** diff --git a/src/checks/oxlint.check.js b/src/checks/oxlint.check.js index a42343f6..7740743f 100644 --- a/src/checks/oxlint.check.js +++ b/src/checks/oxlint.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=t;export default t;const e={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function r(t,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=t}={}){let a=null,l=0;if(l==l){if(!t||"object"!=typeof t||Array.isArray(t))return r.errors=[{params:{type:"object"}}],!1;if(void 0!==t.cache){if("boolean"!=typeof t.cache)return r.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==t.cacheLocation){if("string"!=typeof t.cacheLocation)return r.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.exclude){let e=t.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=r===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.extensions){let e=t.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=r===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.files){let e=t.files,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=r===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.fix){if("boolean"!=typeof t.fix)return r.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.formatter){let e=t.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let r=l;if(!(e instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=r===l,!(o=o||y)){let r=l;if(!(e instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=r===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.ignoreDiagnostics){let s=t.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var g=f===l;if(!(i=i||g)){let r=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(g=r===l,!(i=i||g)){let r=l;if(l===r)if(s&&"object"==typeof s&&!Array.isArray(s)){let r=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(r===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var m=e===l}else m=!0;if(m){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}m=e===l}else m=!0;if(m)if(void 0!==s.severity){let r=s.severity,t=l;if("error"!==r&&"warning"!==r){let r={params:{allowedValues:e.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[r]:a.push(r),l++}m=t===l}else m=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(g=r===l,!(i=i||g)){let r=l;if(l===r)if(Array.isArray(s)){let r=s.length;for(let t=0;t /** @type {FileResult} */ (result).filename, + // Each run is a process of its own that reads the configuration and, for + // Biome, the project again: handed every file at once, a build starts one + // for each 500 files rather than one for each batch webpack builds. + lintsOnce: () => true, readsAcrossFiles, }; diff --git a/src/checks/stylelint.check.js b/src/checks/stylelint.check.js index ab2804dd..0ec726fb 100644 --- a/src/checks/stylelint.check.js +++ b/src/checks/stylelint.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=t;export default t;const e={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function r(t,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=t}={}){let a=null,l=0;if(l==l){if(!t||"object"!=typeof t||Array.isArray(t))return r.errors=[{params:{type:"object"}}],!1;if(void 0!==t.cache){if("boolean"!=typeof t.cache)return r.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==t.cacheLocation){if("string"!=typeof t.cacheLocation)return r.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.exclude){let e=t.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=r===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.extensions){let e=t.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=r===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.files){let e=t.files,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=r===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.fix){if("boolean"!=typeof t.fix)return r.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.formatter){let e=t.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let r=l;if(!(e instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=r===l,!(o=o||y)){let r=l;if(!(e instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=r===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.ignoreDiagnostics){let s=t.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(i=i||m)){let r=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=r===l,!(i=i||m)){let r=l;if(l===r)if(s&&"object"==typeof s&&!Array.isArray(s)){let r=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(r===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var g=e===l}else g=!0;if(g){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}g=e===l}else g=!0;if(g)if(void 0!==s.severity){let r=s.severity,t=l;if("error"!==r&&"warning"!==r){let r={params:{allowedValues:e.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[r]:a.push(r),l++}g=t===l}else g=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=r===l,!(i=i||m)){let r=l;if(l===r)if(Array.isArray(s)){let r=s.length;for(let t=0;t }} Native + */ + +const nodeRequire = createRequire(import.meta.url); + +/** + * 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 {number} error what the native compiler numbers an error + * @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, error, 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}`, + // What is not an error is reported as a warning. + severity: diagnostic.category === native.error ? "error" : "warning", + text: flatten(diagnostic), + formatted: format(diagnostic, native.error, 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) }), + error: loaded.DiagnosticCategory.Error, + 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-program.js b/src/checks/typescript-program.js index 4448564b..51354428 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[], cancelled?: boolean }} 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 }), + }; } /** @@ -434,9 +443,10 @@ function stateFileFor(options, configFile, compilerOptions) { * every rebuild. * @param {Held} held what the last compilation left behind, with what its declarations said * @param {EXPECTED_ANY} program the program built this time + * @param {EXPECTED_ANY=} cancellation what tells a run nobody waits for any more to stop * @returns {Diagnostic[]} what the declarations of all its files say */ -function declarationDiagnostics(held, program) { +function declarationDiagnostics(held, program, cancellation) { // Asked before the semantic diagnostics are: once those are known the // builder has nothing left that it would check again. let reached = held.declarations.size === 0; @@ -444,9 +454,10 @@ function declarationDiagnostics(held, program) { const affected = new Set(); for ( - let next = held.program.getSemanticDiagnosticsOfNextAffectedFile(); + let next = + held.program.getSemanticDiagnosticsOfNextAffectedFile(cancellation); next; - next = held.program.getSemanticDiagnosticsOfNextAffectedFile() + next = held.program.getSemanticDiagnosticsOfNextAffectedFile(cancellation) ) { // The whole program, where a change reaches every file. if (next.affected === program) reached = true; @@ -466,7 +477,7 @@ function declarationDiagnostics(held, program) { file.fileName, !reached && known && !affected.has(file.fileName) ? known - : program.getDeclarationDiagnostics(file), + : program.getDeclarationDiagnostics(file, cancellation), ); } @@ -482,9 +493,10 @@ function declarationDiagnostics(held, program) { * @param {TypeScript} ts the loaded TypeScript * @param {Options} options options * @param {Held} held what the last compilation left behind + * @param {EXPECTED_ANY=} cancellation what tells a run nobody waits for any more to stop * @returns {Found} what it found, and what it read to find it */ -function check(ts, options, held) { +function check(ts, options, held, cancellation) { const context = String(options.context); const configFile = options.configFile || @@ -545,6 +557,7 @@ function check(ts, options, held) { files: [configFile], directories: [], writes: [], + extensions: [], }; } @@ -614,23 +627,63 @@ function check(ts, options, held) { global: true, ...options.diagnosticOptions, }; - const diagnostics = [ - ...unrecoverable, - ...ts.sortAndDeduplicateDiagnostics([ - ...program.getConfigFileParsingDiagnostics(), - ...program.getOptionsDiagnostics(), - ...(kinds.syntactic ? held.program.getSyntacticDiagnostics() : []), - ...(kinds.global ? program.getGlobalDiagnostics() : []), - ...(kinds.declaration && - (parsed.options.declaration || parsed.options.composite) - ? declarationDiagnostics(held, program) - : []), - ...(kinds.semantic ? held.program.getSemanticDiagnostics() : []), - ]), - ]; - + // 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 = []; + const writes = overrides.noEmit + ? [] + : [ + parsed.options.outDir, + parsed.options.declarationDir, + parsed.options.tsBuildInfoFile, + ] + .filter(Boolean) + .map(String); + /** @type {Diagnostic[]} */ + let diagnostics; + + try { + diagnostics = [ + ...unrecoverable, + ...ts.sortAndDeduplicateDiagnostics([ + ...program.getConfigFileParsingDiagnostics(), + ...program.getOptionsDiagnostics(), + ...(kinds.syntactic + ? held.program.getSyntacticDiagnostics(undefined, cancellation) + : []), + ...(kinds.global ? program.getGlobalDiagnostics(cancellation) : []), + ...(kinds.declaration && + (parsed.options.declaration || parsed.options.composite) + ? declarationDiagnostics(held, program, cancellation) + : []), + ...(kinds.semantic + ? held.program.getSemanticDiagnostics(undefined, cancellation) + : []), + ]), + ]; + } catch (err) { + if (!(err instanceof ts.OperationCanceledException)) throw err; + + // The builder keeps what it got through and checks the rest next time. + // Which files the declarations were drained of is lost with the run, so + // the next one works all of them out again. + held.declarations = new Map(); + + return { + cancelled: true, + diagnostics: [], + host, + files, + directories, + writes, + extensions, + }; + } if (stateFile) { try { @@ -646,14 +699,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 +716,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..38ddea40 100644 --- a/src/checks/typescript-worker.js +++ b/src/checks/typescript-worker.js @@ -45,12 +45,23 @@ function setup(options) { /** * Runs the check and answers with what crosses back: a `Diagnostic` holds the * source file it was found in, so each one is described and formatted here. + * @param {string[]} files nothing: the program is the config file's + * @param {Int32Array} stopped set once nobody waits for this run, shared with the plugin * @returns {Promise} what it found, and what it read to find it */ -async function lintFiles() { +async function lintFiles(files, stopped) { const ts = await getTypeScript(); const held = getHeld(compiler, "worker"); - const found = check(ts, checkOptions, held); + // TypeScript asks between one file and the next, which is where a run + // nobody waits for any more stops. + const requested = () => Atomics.load(stopped, 0) === 1; + const cancellation = { + isCancellationRequested: requested, + throwIfCancellationRequested() { + if (requested()) throw new ts.OperationCanceledException(); + }, + }; + const found = check(ts, checkOptions, held, cancellation); // Each on its own, so that what is left after `ignoreDiagnostics` can be // printed without asking the worker again. Each block carries the newline // TypeScript ends it with, which is what separates one from the next, so @@ -70,6 +81,7 @@ async function lintFiles() { writes: found.writes, missing: [...held.missing], seen: [...held.seen], + extensions: found.extensions, }; } diff --git a/src/checks/typescript.check.js b/src/checks/typescript.check.js index bc21ae97..dc48d458 100644 --- a/src/checks/typescript.check.js +++ b/src/checks/typescript.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=s;export default s;const e={type:"object",additionalProperties:!0,properties:{typescriptPath:{type:"string"},configFile:{type:"string"},compilerOptions:{type:"object"},build:{type:"boolean"},mode:{enum:["readonly","write-tsbuildinfo","write-dts","write-references"]},diagnosticOptions:{type:"object",additionalProperties:!1,properties:{syntactic:{type:"boolean"},semantic:{type:"boolean"},declaration:{type:"boolean"},global:{type:"boolean"}}}},allOf:[{$ref:"../entry-options.json"}],$id:"typescript.json"},r={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function t(e,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=e}={}){let a=null,l=0;if(l==l){if(!e||"object"!=typeof e||Array.isArray(e))return t.errors=[{params:{type:"object"}}],!1;if(void 0!==e.cache){if("boolean"!=typeof e.cache)return t.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==e.cacheLocation){if("string"!=typeof e.cacheLocation)return t.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.exclude){let r=e.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=e===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.extensions){let r=e.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=e===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.files){let r=e.files,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=e===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.fix){if("boolean"!=typeof e.fix)return t.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.formatter){let r=e.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let e=l;if(!(r instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=e===l,!(o=o||y)){let e=l;if(!(r instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=e===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.ignoreDiagnostics){let s=e.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(i=i||m)){let e=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(i=i||m)){let e=l;if(l===e)if(s&&"object"==typeof s&&!Array.isArray(s)){let e=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(e===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var g=e===l}else g=!0;if(g){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}g=e===l}else g=!0;if(g)if(void 0!==s.severity){let e=s.severity,t=l;if("error"!==e&&"warning"!==e){let e={params:{allowedValues:r.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[e]:a.push(e),l++}g=t===l}else g=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(i=i||m)){let e=l;if(l===e)if(Array.isArray(s)){let e=s.length;for(let t=0;t} 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} */ @@ -174,6 +218,10 @@ async function create({ key, id: entry, options, compilation }) { // The program is the whole project, so it is built once however many batches // of files the plugin hands over. let checked = false; + // The worker's answer to this compilation's run, which is what a newer + // compilation stops when it takes over. + /** @type {Promise | undefined} */ + let answer; /** * @param {EXPECTED_ANY} one a diagnostic, or what a worker said of one @@ -184,9 +232,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 @@ -231,7 +278,9 @@ async function create({ key, id: entry, options, compilation }) { } if (worker) { - const found = await worker.lintFiles(); + answer = worker.lintFiles(); + + const found = await answer; return remember( { @@ -241,6 +290,7 @@ async function create({ key, id: entry, options, compilation }) { directories: found.directories, writes: found.writes, missing: found.missing, + extensions: found.extensions, }, found.seen, ); @@ -256,10 +306,16 @@ async function create({ key, id: entry, options, compilation }) { directories: found.directories, writes: found.writes, missing: [...held.missing], + extensions: found.extensions, }, [...held.seen], ); }, + // A run of an older compilation is left to finish on the worker while + // webpack builds the next one, which waits for it before checking again. + cancel() { + if (worker && answer) worker.cancel(answer); + }, readFiles() { return read; }, @@ -322,9 +378,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/src/index.js b/src/index.js index 3767b890..a47f3381 100644 --- a/src/index.js +++ b/src/index.js @@ -147,8 +147,8 @@ async function affectedFiles(compiler, check, files) { * Walks the file system for the files a check wants, and says which of them * webpack has just seen change. * @param {Compiler} compiler compiler - * @param {ResolvedCheck} check the check to collect the files of - * @returns {Promise<{ lint: string[], keep: string[] }>} the files to lint, and the ones to report from the last compilation + * @param {ResolvedCheck & { afterwards?: string[] }} check the check to collect the files of + * @returns {Promise<{ lint: string[], keep: string[], after?: string[] }>} the files to lint, the ones to report from the last compilation, and of those the ones to lint again once the build is over */ async function collectFromFileSystem(compiler, check) { const { adapter, wanted, exclude } = check; @@ -170,13 +170,39 @@ async function collectFromFileSystem(compiler, check) { if (readsAcrossFiles(compiler, check)) { const affected = await affectedFiles(compiler, check, found); - if (!affected) return { lint: found, keep: [] }; + if (!check.afterwards) { + if (!affected) return { lint: found, keep: [] }; - /** @type {{ lint: string[], keep: string[] }} */ - const split = { lint: [], keep: [] }; + /** @type {{ lint: string[], keep: string[] }} */ + const split = { lint: [], keep: [] }; + + for (const file of found) { + split[affected.has(toPosixPath(file)) ? "lint" : "keep"].push(file); + } + + return split; + } + + // Told to look again once the build is over: what changed is linted with + // the build, and what else the change reaches is reported from its last + // run until it is linted after it. + const touched = new Set( + [...modifiedFiles].map((file) => toPosixPath(file)), + ); + /** @type {{ lint: string[], keep: string[], after: string[] }} */ + const split = { lint: [], keep: [], after: [] }; for (const file of found) { - split[affected.has(toPosixPath(file)) ? "lint" : "keep"].push(file); + const known = toPosixPath(file); + + if (touched.has(known)) { + split.lint.push(file); + continue; + } + + split.keep.push(file); + + if (!affected || affected.has(known)) split.after.push(file); } return split; @@ -280,6 +306,14 @@ function isAdvisory(options) { }); } +/** + * @param {{ errors?: DiagnosticError, warnings?: DiagnosticError }} report what a run of a check reported + * @returns {string} what it says, to tell two of them apart by + */ +function describeReport({ errors, warnings }) { + return `${errors ? errors.message : ""}\0${warnings ? warnings.message : ""}`; +} + class DiagnosticsWebpackPlugin { /** * @param {Options} options options @@ -457,6 +491,46 @@ class DiagnosticsWebpackPlugin { ); }; + /** + * Lints what a rebuild reported from the last run once the build is over, + * and prints what that changes. What it finds is remembered either way, so + * the next build reports it. + * @param {{ files: string[], afterwards?: string[], compilation: Compilation } & ResolvedCheck} check the check the build ran + * @param {string} reported what the build reported of it + * @param {number} mine which compilation it was run for + */ + const lintAfterBuild = (check, reported, mine) => { + const after = new Set(check.afterwards); + const runner = this.createRunner(check, check.compilation); + + runner.lint([...after]); + runner.keep(check.files.filter((file) => !after.has(file))); + + const report = runner.report(); + + runner.detach(report); + + report.then( + (found) => { + if (generation !== mine || describeReport(found) === reported) { + return; + } + + const logger = compiler.getInfrastructureLogger(LINT_PLUGIN); + + logger.info( + `${check.name}: what the last build reported has changed now that the files it did not rebuild are linted again`, + ); + + if (found.errors) logger.error(found.errors.message); + if (found.warnings) logger.warn(found.warnings.message); + }, + (err) => { + compiler.getInfrastructureLogger(LINT_PLUGIN).error(err.message); + }, + ); + }; + compiler.hooks.compilation.tap(this.key, (compilation) => { generation += 1; @@ -488,6 +562,22 @@ class DiagnosticsWebpackPlugin { // the graph is done and the files a change can reach are known. /** @type {string[]} */ const deferred = []; + // A rebuild of a check reading across files that is told to lint what + // a change reaches once the build is over: those files, reported from + // their last run until then. Nothing when it lints them with the build, + // which one writing an `outputReport` does: that file is written once, + // with every check sharing it, and what comes after would not reach it. + const afterwards = + !late && + check.options.lintDependentsAfterBuild && + !( + check.options.outputReport && check.options.outputReport.filePath + ) && + compiler.watchMode && + !compilation.compiler.isChild() && + compiler.modifiedFiles + ? /** @type {string[]} */ ([]) + : undefined; let scheduled = false; // Handed in batches while webpack builds, unless the check's tool // pays for a run of its own every time it is handed some. @@ -523,6 +613,7 @@ class DiagnosticsWebpackPlugin { return { ...check, + compilation, /** @type {string[]} */ files: [], // The same files, to tell one already listed without a search. @@ -531,6 +622,7 @@ class DiagnosticsWebpackPlugin { pending, kept, deferred, + afterwards, flush, handOver, late, @@ -606,8 +698,12 @@ class DiagnosticsWebpackPlugin { check.collecting = collectFromFileSystem(compiler, check).then( (collected) => { + check.files.push(...collected.lint, ...collected.keep); check.pending.push(...collected.lint); check.kept.push(...collected.keep); + if (collected.after && check.afterwards) { + check.afterwards.push(...collected.after); + } check.flush(true); }, (err) => { @@ -629,10 +725,16 @@ class DiagnosticsWebpackPlugin { ); for (const file of check.deferred.splice(0)) { - (!affected || affected.has(toPosixPath(file)) - ? check.pending - : check.kept - ).push(file); + if (affected && !affected.has(toPosixPath(file))) { + check.kept.push(file); + } else if (check.afterwards) { + // Reported from its last run now, and linted again once + // the build is over. + check.kept.push(file); + check.afterwards.push(file); + } else { + check.pending.push(file); + } } } @@ -737,6 +839,12 @@ class DiagnosticsWebpackPlugin { readLast.set(id, { read, missing, directories, writes }); await watch(check, report); + if (check.afterwards && check.afterwards.length > 0) { + const reported = describeReport(report); + + afterBuild.push(() => lintAfterBuild(check, reported, mine)); + } + // `reportAs` has already dropped whatever it reports as `false`, // so what is left only needs putting where it belongs. for (const [results, reported] of /** @type {const} */ ([ diff --git a/src/options.check.js b/src/options.check.js index f9726a81..2a3ef85f 100644 --- a/src/options.check.js +++ b/src/options.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=a;export default a;const e={type:"object",additionalProperties:!1,properties:{context:{type:"string"},lintOnStart:{type:"boolean"},checks:{type:"array",minItems:1,items:{anyOf:[{type:"string"},{$ref:"./entry-options.json"}]}},cache:{$ref:"./shared-options.json#/properties/cache"},cacheLocation:{$ref:"./shared-options.json#/properties/cacheLocation"},exclude:{$ref:"./shared-options.json#/properties/exclude"},extensions:{$ref:"./shared-options.json#/properties/extensions"},files:{$ref:"./shared-options.json#/properties/files"},fix:{$ref:"./shared-options.json#/properties/fix"},formatter:{$ref:"./shared-options.json#/properties/formatter"},ignoreDiagnostics:{$ref:"./shared-options.json#/properties/ignoreDiagnostics"},outputReport:{$ref:"./shared-options.json#/properties/outputReport"},readsAcrossFiles:{$ref:"./shared-options.json#/properties/readsAcrossFiles"},reportAs:{$ref:"./shared-options.json#/properties/reportAs"},resourceQueryExclude:{$ref:"./shared-options.json#/properties/resourceQueryExclude"},threads:{$ref:"./shared-options.json#/properties/threads"}},required:["checks"],$id:"options.json"},r={anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},s={anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},t={anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]},n=Object.prototype.hasOwnProperty,o={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function i(e,{instancePath:r="",parentData:s,parentDataProperty:t,rootData:n=e}={}){let a=null,l=0;if(l==l){if(!e||"object"!=typeof e||Array.isArray(e))return i.errors=[{params:{type:"object"}}],!1;if(void 0!==e.cache){if("boolean"!=typeof e.cache)return i.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==e.cacheLocation){if("string"!=typeof e.cacheLocation)return i.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.exclude){let r=e.exclude,s=l,t=l,n=!1,o=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=o===l;if(!(n=n||f)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=e===l,n=n||f}if(!n){let e={params:{}};return null===a?a=[e]:a.push(e),l++,i.errors=a,!1}l=t,null!==a&&(t?a.length=t:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.extensions){let r=e.extensions,s=l,t=l,n=!1,o=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=o===l;if(!(n=n||u)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=e===l,n=n||u}if(!n){let e={params:{}};return null===a?a=[e]:a.push(e),l++,i.errors=a,!1}l=t,null!==a&&(t?a.length=t:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.files){let r=e.files,s=l,t=l,n=!1,o=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=o===l;if(!(n=n||c)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=e===l,n=n||c}if(!n){let e={params:{}};return null===a?a=[e]:a.push(e),l++,i.errors=a,!1}l=t,null!==a&&(t?a.length=t:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.fix){if("boolean"!=typeof e.fix)return i.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.formatter){let r=e.formatter,s=l,t=l,n=!1,o=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=o===l;if(!(n=n||y)){let e=l;if(!(r instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=e===l,!(n=n||y)){let e=l;if(!(r instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=e===l,n=n||y}}if(!n){let e={params:{}};return null===a?a=[e]:a.push(e),l++,i.errors=a,!1}l=t,null!==a&&(t?a.length=t:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.ignoreDiagnostics){let r=e.ignoreDiagnostics,s=l,t=l,n=!1,f=l;if("number"!=typeof r){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(n=n||m)){let e=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(n=n||m)){let e=l;if(l===e)if(r&&"object"==typeof r&&!Array.isArray(r)){let e=l;for(let e in r)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(e===l){if(void 0!==r.file){let e=l;if("string"!=typeof r.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var h=e===l}else h=!0;if(h){if(void 0!==r.code){let e=l;if("string"!=typeof r.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}h=e===l}else h=!0;if(h)if(void 0!==r.severity){let e=r.severity,s=l;if("error"!==e&&"warning"!==e){let e={params:{allowedValues:o.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[e]:a.push(e),l++}h=s===l}else h=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(n=n||m)){let e=l;if(l===e)if(Array.isArray(r)){let e=r.length;for(let s=0;s Promise} LintTask */ /** @typedef {JestWorker & { lintFiles: LintTask }} Worker */ /** @typedef {{ lintFiles: LintTask, end: () => Promise }} Pool */ -/** @typedef {{ lintFiles: () => Promise, end: () => Promise }} Solo */ +/** @typedef {{ lintFiles: () => Promise, cancel: (answer: Promise) => void, end: () => Promise }} Solo */ /** * How many threads the user asked for, as a count. A check is spread over one @@ -107,6 +107,13 @@ function createPool(source, size, setupArgs) { function createSolo(source, setupArgs) { if (!isTransferable(setupArgs)) return null; + // Each run is handed a flag it shares with the worker rather than one sent + // to it later: a message would wait behind the run it is meant to stop. A + // worker can be shared by checks of several compilers, so each run has a + // flag of its own and stopping one leaves the rest. + /** @type {WeakMap, Int32Array>} */ + const flags = new WeakMap(); + let worker = /** @type {Worker | null} */ ( new JestWorker(source, { enableWorkerThreads: true, @@ -116,11 +123,26 @@ function createSolo(source, setupArgs) { ); return { - lintFiles: async () => { + // Not async: the promise handed back is the one `cancel` is given. + lintFiles: () => { /* istanbul ignore next */ - if (!worker) return undefined; + if (!worker) return Promise.resolve(undefined); + + const stopped = new Int32Array(new SharedArrayBuffer(4)); + const answer = /** @type {EXPECTED_ANY} */ (worker).lintFiles( + [], + stopped, + ); + + flags.set(answer, stopped); + + return answer; + }, + // Whether the run has started or is still queued. + cancel: (answer) => { + const stopped = flags.get(answer); - return /** @type {Worker} */ (worker).lintFiles([]); + if (stopped) Atomics.store(stopped, 0, 1); }, end: async () => { /* istanbul ignore else */ diff --git a/test/after-build.test.js b/test/after-build.test.js new file mode 100644 index 00000000..ae13a079 --- /dev/null +++ b/test/after-build.test.js @@ -0,0 +1,200 @@ +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +import assert from "node:assert/strict"; +import { rmSync, utimesSync, writeFileSync } from "node:fs"; +import { join, normalize } from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import webpack from "webpack"; + +import DiagnosticsPlugin from "../src/index.js"; + +const fixtures = join(import.meta.dirname, "fixtures"); +const entry = join(fixtures, "after-entry.js"); +const leaf = join(fixtures, "after-leaf.js"); + +// What the check says of the entry, which an edit to the leaf changes: a typed +// rule reading the leaf through the entry's import. +let generation = 0; +let failFormatting = false; + +const adapter = { + name: "across", + resultPath: (/** @type {{ file: string }} */ result) => result.file, + readsAcrossFiles: () => true, + create: async () => ({ + lintFiles: async (/** @type {string[]} */ files) => + files + // A glob hands over `/` where `join` writes `\` on Windows. + .filter((file) => normalize(file) === entry) + .map((file) => ({ file, text: `found ${generation}` })), + getResults: async (/** @type {unknown[]} */ results) => results, + splitResults: (/** @type {unknown[]} */ results) => ({ + errors: results, + warnings: [], + }), + getFormatter: async () => { + if (failFormatting) throw new Error("cannot format"); + + return async (/** @type {{ text: string }[]} */ results) => + results.map((result) => result.text).join("\n"); + }, + cleanup: async () => {}, + }), +}; + +describe("lintDependentsAfterBuild", () => { + let watching; + /** @type {string[]} */ + let logged; + + beforeEach(() => { + generation = 0; + failFormatting = false; + logged = []; + writeFileSync(leaf, "module.exports = 1;\n"); + writeFileSync(entry, "require('./after-leaf');\n"); + + // Written well before the watch starts, or its first rebuild counts them + // as changed along with the edit. + const past = new Date(Date.now() - 60_000); + + for (const file of [entry, leaf]) utimesSync(file, past, past); + }); + + afterEach(async () => { + // Closed before the files go, so no watch of this test sees the next one. + if (watching) { + await new Promise((resolve) => { + watching.close(resolve); + }); + } + watching = undefined; + rmSync(entry, { force: true }); + rmSync(leaf, { force: true }); + }); + + /** + * @param {object} check the entry of `checks` + * @param {(log: string, stats: import("webpack").Stats) => boolean} until when the log says what the test waits for + * @param {() => void} done done + * @param {(stats: import("webpack").Stats) => void=} rebuilt what to do once a rebuild is over + */ + const run = (check, until, done, rebuilt = () => {}) => { + /** @type {import("webpack").Stats | undefined} */ + let stats; + const compiler = webpack({ + context: fixtures, + entry: "./after-entry.js", + mode: "development", + output: { path: join(import.meta.dirname, "outputs") }, + infrastructureLogging: { + level: "info", + stream: /** @type {EXPECTED_ANY} */ ({ + isTTY: false, + write: (/** @type {string} */ text) => { + logged.push(text); + + if (until(logged.join(""), stats)) done(); + }, + }), + }, + plugins: [ + new DiagnosticsPlugin({ + checks: [{ use: adapter, lintDependentsAfterBuild: true, ...check }], + }), + ], + }); + let first = true; + + watching = compiler.watch({}, (err, current) => { + assert.strictEqual(err, null); + stats = current; + + if (!first) { + rebuilt(current); + + return; + } + + first = false; + generation = 1; + writeFileSync(leaf, "module.exports = 2;\n"); + }); + }; + + it("should print what changes once the rest is linted, and carry it into the next build", (t, done) => { + let printed = false; + + run( + {}, + (log, stats) => { + if (printed || !/found 1/u.test(log)) return false; + + printed = true; + // The build itself reported the entry from its last run. + assert.match(stats.compilation.errors[0].message, /found 0/u); + assert.match(log, /what the last build reported has changed/u); + + // The next build reports what was found after this one. + writeFileSync(leaf, "module.exports = 3;\n"); + + return false; + }, + done, + (stats) => { + if (!printed) return; + + assert.match(stats.compilation.errors[0].message, /found 1/u); + done(); + }, + ); + }); + + it("should lint everything with the build for a check writing an output report", (t, done) => { + run( + { outputReport: { filePath: "after-report.txt" } }, + () => false, + done, + (stats) => { + // Nothing waits for after the build: the entry is reported as found. + assert.match(stats.compilation.errors[0].message, /found 1/u); + done(); + }, + ); + }); + + it("should do the same for a check that globs its files", (t, done) => { + run( + { files: ["after-entry.js", "after-leaf.js"] }, + (log) => /found 1/u.test(log), + done, + ); + }); + + it("should do the same for a check told it reads across files", (t, done) => { + // An adapter that says nothing of it, answered for by the option. + const { readsAcrossFiles, ...silent } = adapter; + + assert.strictEqual(typeof readsAcrossFiles, "function"); + run( + { use: silent, readsAcrossFiles: true }, + (log) => /found 1/u.test(log), + done, + ); + }); + + it("should print why linting the rest failed", (t, done) => { + run( + {}, + (log) => /cannot format/u.test(log), + done, + () => { + // Formatting fails once the build is over, so only the run after it + // has nowhere to report but the terminal. + failFormatting = true; + }, + ); + }); +}); 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..591ddfda 100644 --- a/test/import-graph.test.js +++ b/test/import-graph.test.js @@ -281,7 +281,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 +371,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/incremental.test.js b/test/incremental.test.js index d6cbaa8b..de5dde43 100644 --- a/test/incremental.test.js +++ b/test/incremental.test.js @@ -190,6 +190,64 @@ describe("incremental", () => { }); }; + /** + * Rebuilds after an edit to the leaf with `lintDependentsAfterBuild` set, + * and says what was linted during the build and after it. + * @param {object} options plugin options + * @param {() => void} done done + */ + const afterBuild = (options, done) => { + writeThree(); + + let first = true; + + watch = pack("watch", { + eslintPath, + lintDependentsAfterBuild: true, + ...options, + }).watch({}, (err) => { + assert.strictEqual(err, null); + + if (first) { + first = false; + require(eslintPath)._reset(); + require(eslintPath)._readAcrossFiles(true); + writeFileSync(leaf, "const leaf = 2;\n"); + + return; + } + + // The build waits only for what changed. + const during = linted(); + + assert.strictEqual(during.length, 1); + assert.match(during[0], /watch-leaf\.js/u); + + // What the change reaches is linted once the build is over, and what + // it does not reach is not linted at all. + setTimeout(() => { + rmSync(other, { force: true }); + + const after = linted(); + + assert.ok(after.some((file) => /watch-entry\.js/u.test(file))); + assert.ok(after.every((file) => !/watch-other\.js/u.test(file))); + done(); + }, 500); + }); + }; + + it("should lint after the build only what a change reaches among the modules when told to", (t, done) => { + afterBuild({}, done); + }); + + it("should lint after the build only what a change reaches among the files it globs when told to", (t, done) => { + afterBuild( + { files: ["watch-entry.js", "watch-leaf.js", "watch-other.js"] }, + done, + ); + }); + it("should lint again what a change reaches among the files it globs", (t, done) => { writeThree(); rebuild( @@ -203,6 +261,68 @@ describe("incremental", () => { }); }); + it("should lint what did not change after the build when told to", (t, done) => { + writeFileSync(leaf, "const leaf = 1;\n"); + writeFileSync(entry, "require('./watch-leaf');\nconst entry = 1;\n"); + require(eslintPath)._reset(); + require(eslintPath)._readAcrossFiles(true); + + // Written well before the watch starts, or its first rebuild counts them as + // changed along with the edit. + const past = new Date(Date.now() - 60_000); + + for (const file of [entry, leaf]) utimesSync(file, past, past); + + // eslint-disable-next-line no-use-before-define + let next = firstPass; + const compiler = pack("watch", { + eslintPath, + lintDependentsAfterBuild: true, + }); + + watch = compiler.watch({}, (err, stats) => next(err, stats)); + + function secondPass(err) { + assert.strictEqual(err, null); + + // The build waited only for what it rebuilt. + const during = linted(); + + assert.strictEqual(during.length, 1); + assert.match(during[0], /watch-leaf\.js/u); + + // The rest is linted once it is over. + setTimeout(() => { + assert.ok(linted().some((file) => /watch-entry\.js/u.test(file))); + done(); + }, 500); + } + + function firstPass(err) { + assert.strictEqual(err, null); + assert.strictEqual(linted().length, 2); + + require(eslintPath)._reset(); + require(eslintPath)._readAcrossFiles(true); + next = secondPass; + writeFileSync(leaf, "const leaf = 2;\n"); + } + }); + + it("should lint everything with a build that is not watched, even when told to wait", async () => { + writeFileSync(leaf, "const leaf = 1;\n"); + writeFileSync(entry, "require('./watch-leaf');\nconst entry = 1;\n"); + require(eslintPath)._reset(); + require(eslintPath)._readAcrossFiles(true); + + await pack("watch", { + eslintPath, + lintDependentsAfterBuild: true, + }).runAsync(); + + assert.strictEqual(linted().length, 2); + }); + it("should turn the tool's cache off where a rule reads other files", (t, done) => { writeFileSync(leaf, "const leaf = 1;\n"); writeFileSync(entry, "require('./watch-leaf');\nconst entry = 1;\n"); 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/store.test.js b/test/store.test.js index e9059584..09b7f53a 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -107,3 +107,103 @@ describe("store", () => { assert.deepStrictEqual(await reported(second), []); }); }); + +describe("cancel", () => { + /** + * @param {string[]} cancelled where each stopped run is written down + * @returns {EXPECTED_ANY} an adapter whose runs can be stopped + */ + function adapterCancelled(cancelled) { + const adapter = adapterFinding([]); + const counted = { + ...adapter, + runs: 0, + create: async () => { + const instance = await adapter.create(); + + return { + ...instance, + // Slow enough for a second compiler to join it. + lintFiles: async (/** @type {string[]} */ files) => { + counted.runs += 1; + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + return instance.lintFiles(files); + }, + cancel: () => cancelled.push("cancelled"), + }; + }, + }; + + return counted; + } + + /** + * @param {EXPECTED_ANY} runner the runner whose report nobody waits for + * @returns {Promise} once the runner has been left to finish + */ + async function leave(runner) { + runner.detach(runner.report()); + } + + it("should stop a run of an older compilation nothing else waits for", async () => { + const cancelled = []; + const adapter = adapterCancelled(cancelled); + const compiler = { outputPath: "/out" }; + const first = runnerFor(adapter, compiler); + + first.lint(["a.css"]); + await leave(first); + await reported(runnerFor(adapter, compiler)); + + assert.deepStrictEqual(cancelled, ["cancelled"]); + }); + + it("should leave running a run another compiler joined", async () => { + const cancelled = []; + const adapter = adapterCancelled(cancelled); + const one = { outputPath: "/one" }; + const first = runnerFor(adapter, one); + const other = runnerFor(adapter, { outputPath: "/two" }); + + // Both lint the same files at once, so the second joins the first's run. + first.lint(["a.css"]); + other.lint(["a.css"]); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + await leave(first); + await reported(runnerFor(adapter, one)); + await reported(other); + + assert.deepStrictEqual(cancelled, []); + }); + + it("should not let a compiler join a run that was stopped", async () => { + const cancelled = []; + const adapter = adapterCancelled(cancelled); + const one = { outputPath: "/one" }; + const first = runnerFor(adapter, one); + + first.lint(["a.css"]); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + await leave(first); + + // The next compilation stops the run while it is still going. + const next = runnerFor(adapter, one); + const other = runnerFor(adapter, { outputPath: "/two" }); + + other.lint(["a.css"]); + await Promise.all([reported(next), reported(other)]); + + assert.deepStrictEqual(cancelled, ["cancelled"]); + // The other compiler ran the files itself rather than taking the + // stopped run's answer. + assert.strictEqual(adapter.runs, 2); + }); +}); diff --git a/test/typescript/cancel.test.js b/test/typescript/cancel.test.js new file mode 100644 index 00000000..3301cddc --- /dev/null +++ b/test/typescript/cancel.test.js @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, it } from "node:test"; + +import { countThreads } from "../../src/threads.js"; + +import pack from "./utils/pack.js"; + +const typescriptPath = join(import.meta.dirname, "mock/typescript-stall"); + +const fixture = join(import.meta.dirname, "fixtures", "cancel"); +const checked = join(fixture, "checked.ts"); +const configFile = join(fixture, "tsconfig.json"); +const trigger = join(fixture, "trigger.js"); + +describe("cancel", () => { + let watch; + let outcome; + + afterEach(() => { + if (watch) watch.close(); + + rmSync(checked, { force: true }); + rmSync(configFile, { force: true }); + rmSync(trigger, { force: true }); + rmSync(outcome, { force: true }); + }); + + // The check only runs on a worker of its own where there are threads to + // spare, and only there can a run be stopped while it goes on. + it( + "should stop a run nobody waits for once the next build starts", + { skip: countThreads("auto") < 2 }, + (t, done) => { + outcome = join( + mkdtempSync(join(tmpdir(), "typescript-stall-")), + "outcome", + ); + // Read by the stalling TypeScript, on the worker the check runs on. + process.env.TYPESCRIPT_STALL_OUTCOME = outcome; + + writeFileSync( + configFile, + `${JSON.stringify({ + compilerOptions: { strict: true, skipLibCheck: true }, + include: ["*.ts"], + })}\n`, + ); + writeFileSync(trigger, "export const trigger = 1;\n"); + writeFileSync(checked, "export const fine = 1;\n"); + + /** @type {string[]} */ + const logged = []; + const quiet = () => {}; + const started = Date.now(); + let step = 0; + + const compiler = pack( + "cancel", + // Logged rather than reported, which is what leaves a run to finish + // after the build it was started for. + { threads: "auto", typescriptPath, reportAs: "log" }, + { + infrastructureLogging: { + level: "error", + console: { + error: (/** @type {string} */ message) => { + logged.push(message); + + if (!/TS2322/u.test(message)) return; + + assert.strictEqual(readFileSync(outcome, "utf8"), "cancelled"); + // What the stale run found is never printed. + assert.strictEqual(logged.length, 1); + assert.ok(Date.now() - started < 30000); + done(); + }, + warn: quiet, + info: quiet, + log: quiet, + debug: quiet, + trace: quiet, + group: quiet, + groupEnd: quiet, + groupCollapsed: quiet, + status: quiet, + clear: quiet, + profile: quiet, + profileEnd: quiet, + }, + }, + }, + ); + + watch = compiler.watch({}, (err, stats) => { + assert.strictEqual(err, null); + + const changed = [...(stats.compilation.compiler.modifiedFiles || [])]; + + if (step === 0) { + // A rebuild webpack starts for what was written before it watched. + if (changed.length > 0) return; + + step = 1; + // The run this build leaves behind stalls until it is cancelled. + writeFileSync(checked, "// stall\nexport const fine = 2;\n"); + + return; + } + + if (step === 1) { + // Webpack names the folder that holds it too. + const others = changed.filter((file) => file !== fixture); + + if (others.length !== 1 || others[0] !== checked) { + // A rebuild for what was written before the watch, late: the + // edit is made again so that a run is left with it. + writeFileSync( + checked, + `// stall\nexport const fine = ${Date.now()};\n`, + ); + + return; + } + + step = 2; + + // The worker reads the file when its run gets to it, so the next + // edit waits until that run is stuck on this one. + const stuck = setInterval(() => { + if (!existsSync(outcome)) return; + + clearInterval(stuck); + assert.strictEqual(readFileSync(outcome, "utf8"), "stalling"); + writeFileSync(checked, "export const wrong: string = 42;\n"); + }, 10); + } + }); + }, + ); +}); diff --git a/test/typescript/fixtures/cancel/index.js b/test/typescript/fixtures/cancel/index.js new file mode 100644 index 00000000..aa624046 --- /dev/null +++ b/test/typescript/fixtures/cancel/index.js @@ -0,0 +1 @@ +import "./trigger.js"; 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-stall/index.js b/test/typescript/mock/typescript-stall/index.js new file mode 100644 index 00000000..692f89f1 --- /dev/null +++ b/test/typescript/mock/typescript-stall/index.js @@ -0,0 +1,62 @@ +// Real TypeScript, except that a program holding a file marked to stall keeps +// checking it until the run is cancelled, and says which way it ended. +const { writeFileSync } = require("node:fs"); + +const typescript = require("typescript"); + +const LIMIT = 30000; +const pause = new Int32Array(new SharedArrayBuffer(4)); + +/** + * @param {import("typescript").CancellationToken=} token what a run is cancelled through + */ +function stall(token) { + const started = Date.now(); + + writeFileSync(process.env.TYPESCRIPT_STALL_OUTCOME, "stalling"); + + while (Date.now() - started < LIMIT) { + if (token && token.isCancellationRequested()) { + writeFileSync(process.env.TYPESCRIPT_STALL_OUTCOME, "cancelled"); + token.throwIfCancellationRequested(); + } + + Atomics.wait(pause, 0, 0, 5); + } + + writeFileSync(process.env.TYPESCRIPT_STALL_OUTCOME, "timed out"); +} + +/** + * @param {(...args: unknown[]) => import("typescript").BuilderProgram} make the builder to wrap + * @returns {(...args: unknown[]) => import("typescript").BuilderProgram} it, stalling where a file asks it to + */ +const stalling = + (make) => + (...args) => { + const builder = make(...args); + const check = builder.getSemanticDiagnostics.bind(builder); + + builder.getSemanticDiagnostics = (file, token) => { + const stalls = builder + .getProgram() + .getSourceFiles() + .some((source) => source.text.includes("// stall")); + + if (stalls) stall(token); + + return check(file, token); + }; + + return builder; + }; + +module.exports = { + ...typescript, + createSemanticDiagnosticsBuilderProgram: stalling( + typescript.createSemanticDiagnosticsBuilderProgram, + ), + createEmitAndSemanticDiagnosticsBuilderProgram: stalling( + typescript.createEmitAndSemanticDiagnosticsBuilderProgram, + ), +}; 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/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..289ea87b 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,23 @@ export type Runner = { detach: (report: Promise) => void; }; export type ResultStore = Map; +export type SharedRun = { + key: string; + run: Promise; + joined: boolean; +}; +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/index.d.ts b/types/checks/index.d.ts index 513ad84b..9398736c 100644 --- a/types/checks/index.d.ts +++ b/types/checks/index.d.ts @@ -86,6 +86,10 @@ export type CheckInstance = { * releases whatever the tool holds after a run */ cleanup: () => Promise; + /** + * stops a run of an older compilation that is still going, which nothing reports any more; what it answers with is dropped + */ + cancel?: (() => void) | undefined; }; /** * What a check shipped outside this package has to provide; everything the @@ -241,6 +245,7 @@ export type CheckAdapter = { * @property {((results: CheckResult[], keep: (message: Message) => boolean) => CheckResult[])=} filterResults keeps what a predicate accepts of every result, which is what `ignore` is applied through * @property {(formatter?: FormatterOption) => Promise} getFormatter loads a formatter, falling back to the tool's default one * @property {() => Promise} cleanup releases whatever the tool holds after a run + * @property {(() => void)=} cancel stops a run of an older compilation that is still going, which nothing reports any more; what it answers with is dropped */ /** * What a check shipped outside this package has to provide; everything the 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/stylelint.check.d.ts b/types/checks/stylelint.check.d.ts index 8f6d87b0..a990625b 100644 --- a/types/checks/stylelint.check.d.ts +++ b/types/checks/stylelint.check.d.ts @@ -10,8 +10,8 @@ export function validate( rootData?: any; }, ): any; -export default t; -declare function t( +export default r; +declare function r( e: any, { instancePath: s, diff --git a/types/checks/typescript-native.d.ts b/types/checks/typescript-native.d.ts new file mode 100644 index 00000000..9bd1ced6 --- /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; + 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; + error: number; + 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-program.d.ts b/types/checks/typescript-program.d.ts index 0f2bbe4f..6474e49d 100644 --- a/types/checks/typescript-program.d.ts +++ b/types/checks/typescript-program.d.ts @@ -14,6 +14,8 @@ export type Found = { files: string[]; directories: string[]; writes: string[]; + extensions: string[]; + cancelled?: boolean; }; export type Held = { signature: string; @@ -39,9 +41,15 @@ export type Held = { * @param {TypeScript} ts the loaded TypeScript * @param {Options} options options * @param {Held} held what the last compilation left behind + * @param {EXPECTED_ANY=} cancellation what tells a run nobody waits for any more to stop * @returns {Found} what it found, and what it read to find it */ -export function check(ts: TypeScript, options: Options, held: Held): Found; +export function check( + ts: TypeScript, + options: Options, + held: Held, + cancellation?: EXPECTED_ANY | undefined, +): Found; /** * What a diagnostic says, as data: a `Diagnostic` holds the source file it was * found in, so this is what can be read once it has left the check. diff --git a/types/checks/typescript-worker.d.ts b/types/checks/typescript-worker.d.ts index c2bbbf9c..490a01a4 100644 --- a/types/checks/typescript-worker.d.ts +++ b/types/checks/typescript-worker.d.ts @@ -4,9 +4,14 @@ export type TypeScript = EXPECTED_ANY; /** * Runs the check and answers with what crosses back: a `Diagnostic` holds the * source file it was found in, so each one is described and formatted here. + * @param {string[]} files nothing: the program is the config file's + * @param {Int32Array} stopped set once nobody waits for this run, shared with the plugin * @returns {Promise} what it found, and what it read to find it */ -export function lintFiles(): Promise; +export function lintFiles( + files: string[], + stopped: Int32Array, +): Promise; /** * @param {Options} options the options the check was configured with */ 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 diff --git a/types/options.d.ts b/types/options.d.ts index 00c7b3c2..aea28177 100644 --- a/types/options.d.ts +++ b/types/options.d.ts @@ -48,6 +48,10 @@ export type SharedOptions = { * what a check finds that is not reported */ ignoreDiagnostics?: Ignore | undefined; + /** + * whether a watch rebuild lints the files that can turn on a change after the build rather than during it + */ + lintDependentsAfterBuild?: boolean | undefined; /** * whether what a check reports of one file can turn on another, read from the tool's configuration when left out */ diff --git a/types/threads.d.ts b/types/threads.d.ts index 3252c9da..6ce9ca58 100644 --- a/types/threads.d.ts +++ b/types/threads.d.ts @@ -10,6 +10,7 @@ export type Pool = { }; export type Solo = { lintFiles: () => Promise; + cancel: (answer: Promise) => void; end: () => Promise; }; export type EXPECTED_ANY = any; @@ -18,7 +19,7 @@ export type EXPECTED_ANY = any; /** @typedef {(files: string[]) => Promise} LintTask */ /** @typedef {JestWorker & { lintFiles: LintTask }} Worker */ /** @typedef {{ lintFiles: LintTask, end: () => Promise }} Pool */ -/** @typedef {{ lintFiles: () => Promise, end: () => Promise }} Solo */ +/** @typedef {{ lintFiles: () => Promise, cancel: (answer: Promise) => void, end: () => Promise }} Solo */ /** * How many threads the user asked for, as a count. A check is spread over one * fewer thread than the machine has, leaving webpack the one it builds on, and From c7bdd37ec0f86af10d75a9a6998437eaa699b64f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:56:46 +0000 Subject: [PATCH 2/6] test: cover the TypeScript 7 check's error paths rather than ignore them The line under a diagnostic is printed from the text the native compiler checked (Program.getSourceFile) rather than read from disk again, and a file gone since webpack saw it is told by statSync without throwing, so neither needs a catch. A project with no tsconfig.json above it is now tested from a directory outside this repository. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01App18sYbe6Vf6HDCjCXbvf --- src/checks/typescript-native.js | 48 ++++++++++++++++++--------------- test/typescript/native.test.js | 22 ++++++++++++++- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/checks/typescript-native.js b/src/checks/typescript-native.js index faa61f06..8e1baac1 100644 --- a/src/checks/typescript-native.js +++ b/src/checks/typescript-native.js @@ -93,9 +93,6 @@ function findConfigFile(options) { 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'.`, @@ -121,7 +118,7 @@ function flatten(diagnostic) { * @param {NativeDiagnostic} diagnostic one of what a run found * @param {number} error what the native compiler numbers an error * @param {string} context what the file is named relative to - * @param {Map} texts the source of each file, read once + * @param {Map} texts the text each file was checked as * @returns {string} the diagnostic, formatted */ function format(diagnostic, error, context, texts) { @@ -131,18 +128,7 @@ function format(diagnostic, error, context, texts) { 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 text = texts.get(diagnostic.fileName) || ""; const before = text.slice(0, diagnostic.pos).split(/\r?\n/u); const line = before.length; @@ -180,11 +166,10 @@ function changesSince(native, compiler) { continue; } - try { - if (statSync(file).isFile()) created.push(file); - } catch /* istanbul ignore next */ { - // Gone again, which is what `removedFiles` says. - } + // Gone again, which is what `removedFiles` says, or a folder. + const stat = statSync(file, { throwIfNoEntry: false }); + + if (stat && stat.isFile()) created.push(file); } return { @@ -248,8 +233,27 @@ async function checkNative(native, options, compiler) { : [], kinds.semantic ? program.getSemanticDiagnostics() : [], ]); + // The text each file was checked as, which is what the line under a + // diagnostic is printed from, rather than what is on disk by now. + const named = new Set( + found + .flat() + .flatMap((diagnostic) => + diagnostic.fileName ? [diagnostic.fileName] : [], + ), + ); /** @type {Map} */ - const texts = new Map(); + const texts = new Map( + await Promise.all( + [...named].map( + async (fileName) => + /** @type {[string, string]} */ ([ + fileName, + ((await program.getSourceFile(fileName)) || { text: "" }).text, + ]), + ), + ), + ); /** @type {Set} */ const seen = new Set(); /** @type {Described[]} */ diff --git a/test/typescript/native.test.js b/test/typescript/native.test.js index 61866b6d..679f5286 100644 --- a/test/typescript/native.test.js +++ b/test/typescript/native.test.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; -import { rmSync, writeFileSync } from "node:fs"; +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"; @@ -110,6 +111,25 @@ describe("typescript 7", () => { } }); + 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, From ab97c4816be03e9f48ea07662a88fe4765e00da9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:00:09 +0000 Subject: [PATCH 3/6] fix: match the files webpack saw change against the native compiler's on Windows webpack names a changed file with the platform's separators, the native compiler with forward slashes, and Windows tells no case apart, so both are now compared by the same key. The cancel test accepts a run already stopped by the time it looks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01App18sYbe6Vf6HDCjCXbvf --- src/checks/typescript-native.js | 19 +++++++++++++++++-- test/typescript/cancel.test.js | 8 +++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/checks/typescript-native.js b/src/checks/typescript-native.js index 8e1baac1..67c69f7d 100644 --- a/src/checks/typescript-native.js +++ b/src/checks/typescript-native.js @@ -6,6 +6,8 @@ import { createRequire } from "node:module"; import { dirname, join, relative, 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 */ @@ -147,6 +149,19 @@ function format(diagnostic, error, context, texts) { ); } +/** + * 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. @@ -161,7 +176,7 @@ function changesSince(native, compiler) { const created = []; for (const file of compiler.modifiedFiles || []) { - if (native.files.has(file)) { + if (native.files.has(keyOf(file))) { changed.push(file); continue; } @@ -277,7 +292,7 @@ async function checkNative(native, options, compiler) { }); } - native.files = new Set(await program.getSourceFileNames()); + native.files = new Set((await program.getSourceFileNames()).map(keyOf)); return { diagnostics, diff --git a/test/typescript/cancel.test.js b/test/typescript/cancel.test.js index 3301cddc..72d89b7b 100644 --- a/test/typescript/cancel.test.js +++ b/test/typescript/cancel.test.js @@ -139,7 +139,13 @@ describe("cancel", () => { if (!existsSync(outcome)) return; clearInterval(stuck); - assert.strictEqual(readFileSync(outcome, "utf8"), "stalling"); + // A rebuild webpack starts late on its own, as it can on Windows, + // has stopped the run by now as well: either way it is stuck on + // this file or was stopped while it was. + assert.match( + readFileSync(outcome, "utf8"), + /^(stalling|cancelled)$/u, + ); writeFileSync(checked, "export const wrong: string = 42;\n"); }, 10); } From dfaddfa9a0ccb4f42e8d4b09d11bc461c431f3c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:19:59 +0000 Subject: [PATCH 4/6] fix: take turns on a shared TypeScript 7 process, and forget a stopped run Compilers checking the same project share the native compiler's process, and a run replaces the snapshot and disposes of the last one, which a run for another compiler could still be reading: runs on one process now take turns. A run stopped for a newer compilation found nothing, so it is no longer kept as what a rebuild the program does not see is answered with. A directory no config file covers is now tested in the import graph. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01App18sYbe6Vf6HDCjCXbvf --- src/checks/typescript-native.js | 29 ++++++++++++++++++++---- src/checks/typescript-worker.js | 1 + src/checks/typescript.js | 13 +++++++++++ test/import-graph.test.js | 9 ++++++++ test/typescript/native.test.js | 40 ++++++++++++++++++++++++++++++++- 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/checks/typescript-native.js b/src/checks/typescript-native.js index 67c69f7d..92f0a77f 100644 --- a/src/checks/typescript-native.js +++ b/src/checks/typescript-native.js @@ -21,7 +21,7 @@ import { getTypeScriptOptions } from "./typescript-program.js"; */ /** * A client of the native compiler and what it last saw of the project. - * @typedef {{ api: EXPECTED_ANY, error: number, snapshot: EXPECTED_ANY, files: Set }} Native + * @typedef {{ api: EXPECTED_ANY, error: number, snapshot: EXPECTED_ANY, files: Set, turn: Promise }} Native */ const nodeRequire = createRequire(import.meta.url); @@ -195,14 +195,13 @@ function changesSince(native, compiler) { } /** - * 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. + * 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 checkNative(native, options, compiler) { +async function checkOnce(native, options, compiler) { const cannot = unsupported(options); if (cannot) { @@ -304,6 +303,27 @@ async function checkNative(native, options, compiler) { }; } +/** + * 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 @@ -316,6 +336,7 @@ function startNative(loaded, options) { error: loaded.DiagnosticCategory.Error, snapshot: undefined, files: new Set(), + turn: Promise.resolve(), }; } diff --git a/src/checks/typescript-worker.js b/src/checks/typescript-worker.js index 38ddea40..fa3d3a66 100644 --- a/src/checks/typescript-worker.js +++ b/src/checks/typescript-worker.js @@ -76,6 +76,7 @@ async function lintFiles(files, stopped) { return { diagnostics, + cancelled: Boolean(found.cancelled), files: found.files, directories: found.directories, writes: found.writes, diff --git a/src/checks/typescript.js b/src/checks/typescript.js index 4b8ff49e..07fbc060 100644 --- a/src/checks/typescript.js +++ b/src/checks/typescript.js @@ -282,6 +282,19 @@ async function create({ key, id: entry, options, compilation }) { const found = await answer; + // A stopped run found nothing a later rebuild can be answered with. + if (found.cancelled) { + runs.delete(id); + ({ read, directories, writes, missing } = { + read: found.files, + directories: found.directories, + writes: found.writes, + missing: found.missing, + }); + + return []; + } + return remember( { diagnostics: found.diagnostics, diff --git a/test/import-graph.test.js b/test/import-graph.test.js index 591ddfda..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", diff --git a/test/typescript/native.test.js b/test/typescript/native.test.js index 679f5286..6502a937 100644 --- a/test/typescript/native.test.js +++ b/test/typescript/native.test.js @@ -5,7 +5,12 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, it } from "node:test"; -import { loadNative } from "../../src/checks/typescript-native.js"; +import { + checkNative, + loadNative, + startNative, + stopNative, +} from "../../src/checks/typescript-native.js"; import pack from "./utils/pack.js"; @@ -39,6 +44,39 @@ describe("typescript 7", () => { ); }); + 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 = []; + const snapshot = native.api.updateSnapshot.bind(native.api); + + native.api.updateSnapshot = async (/** @type {unknown} */ change) => { + order.push("start"); + + const taken = await snapshot(change); + + order.push("taken"); + + return taken; + }; + + 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(); From f6fd1bd901ad8243dbb299677575c278b5b2a953 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 11:08:42 +0000 Subject: [PATCH 5/6] refactor: check TypeScript 7 through its 7.1 API, and drop what did not pay off TypeScript 7.1 ships its API as typescript/async, makes snapshots with createSnapshot and snapshot.update, and formats diagnostics itself. The native check now uses that API, found by what the package exports rather than by its version, and TypeScript's own formatter in place of one written here. TypeScript 7.0's API was an unstable preview with none of these calls; a TypeScript offering no API to check with now says so. Measured again on this branch, two additions did not earn their place: - lintDependentsAfterBuild finished the build sooner but printed an error it found in an importer no sooner (1.96-2.64 s against 2.09-2.21 s without it), at the cost of an option and a second report. - cancel() on the checker API only ever served a TypeScript 6 check on a worker reporting as advisory, and TypeScript 7 does not use it. Both are removed, with what they needed: a stop flag per worker run, bookkeeping of which runs another compiler joined, and their tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01App18sYbe6Vf6HDCjCXbvf --- .changeset/lint-dependents-after-build.md | 5 - .../ts-share-program-across-compilers.md | 2 +- .changeset/ts-stop-stale-runs.md | 5 - .changeset/typescript-7.md | 2 +- README.md | 65 ++---- package-lock.json | 188 ++++++++-------- package.json | 4 +- src/check.js | 51 +---- src/checks/biome.check.js | 2 +- src/checks/biome.js | 6 +- src/checks/eslint.check.js | 2 +- src/checks/index.js | 1 - src/checks/oxlint.check.js | 2 +- src/checks/oxlint.js | 6 +- src/checks/stylelint.check.js | 2 +- src/checks/typescript-native.js | 115 +++------- src/checks/typescript-program.js | 77 +++---- src/checks/typescript-worker.js | 16 +- src/checks/typescript.check.js | 2 +- src/checks/typescript.js | 26 +-- src/index.js | 128 +---------- src/options.check.js | 2 +- src/options.js | 1 - src/options.json | 3 - src/shared-options.json | 4 - src/threads.js | 30 +-- test/after-build.test.js | 200 ------------------ test/incremental.test.js | 120 ----------- test/store.test.js | 100 --------- test/typescript/cancel.test.js | 155 -------------- test/typescript/fixtures/cancel/index.js | 1 - .../typescript/mock/typescript-stall/index.js | 62 ------ .../mock/typescript-version-only/index.js | 2 + test/typescript/native.test.js | 32 ++- types/check.d.ts | 5 - types/checks/index.d.ts | 5 - types/checks/stylelint.check.d.ts | 4 +- types/checks/typescript-native.d.ts | 12 +- types/checks/typescript-program.d.ts | 9 +- types/checks/typescript-worker.d.ts | 7 +- types/options.d.ts | 4 - types/threads.d.ts | 3 +- 42 files changed, 259 insertions(+), 1209 deletions(-) delete mode 100644 .changeset/lint-dependents-after-build.md delete mode 100644 .changeset/ts-stop-stale-runs.md delete mode 100644 test/after-build.test.js delete mode 100644 test/typescript/cancel.test.js delete mode 100644 test/typescript/fixtures/cancel/index.js delete mode 100644 test/typescript/mock/typescript-stall/index.js create mode 100644 test/typescript/mock/typescript-version-only/index.js diff --git a/.changeset/lint-dependents-after-build.md b/.changeset/lint-dependents-after-build.md deleted file mode 100644 index 0c6b17b2..00000000 --- a/.changeset/lint-dependents-after-build.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"diagnostics-webpack-plugin": minor ---- - -Add `lintDependentsAfterBuild`: a watch rebuild under typed linting lints what it rebuilt with the build and the rest once the build is over, printing what changes and carrying it into the next build. diff --git a/.changeset/ts-share-program-across-compilers.md b/.changeset/ts-share-program-across-compilers.md index 78a11acf..912b9bd8 100644 --- a/.changeset/ts-share-program-across-compilers.md +++ b/.changeset/ts-share-program-across-compilers.md @@ -2,4 +2,4 @@ "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. A run one compiler stops no longer stops another's on the shared worker. Under TypeScript 7 the native compiler's process is shared the same way. +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/ts-stop-stale-runs.md b/.changeset/ts-stop-stale-runs.md deleted file mode 100644 index 6361e5b2..00000000 --- a/.changeset/ts-stop-stale-runs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"diagnostics-webpack-plugin": minor ---- - -Stop a TypeScript run that nothing will report any more. A check that `reportAs` keeps off the compilation runs after a watch rebuild. When it is on a worker of its own, it now stops a run of an older rebuild as soon as the next one starts, instead of finishing a check whose report would be dropped. Over 600 files, an edit made during a long check was reported after 2.3–2.6 s instead of 4.2–4.5 s, with about 2–3 CPU-seconds less work. Check adapters can stop such a run too, through an optional `cancel()` on their instance. diff --git a/.changeset/typescript-7.md b/.changeset/typescript-7.md index de8bff52..5f1d9e12 100644 --- a/.changeset/typescript-7.md +++ b/.changeset/typescript-7.md @@ -2,4 +2,4 @@ "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. +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/README.md b/README.md index a3032ce7..b277dce4 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,6 @@ Every check to run is an entry in `checks`, named by its `use`. The list may nam | [`outputReport`](#outputreport) | Shared | A file the same results are written to. | | [`readsAcrossFiles`](#readsacrossfiles) | Shared | Whether a file's results can turn on another, so a rebuild lints importers. | | [`reportAs`](#reportas) | Shared | What the build carries: an error, a warning, a log line, or nothing. | -| [`lintDependentsAfterBuild`](#lintdependentsafterbuild) | Shared | Whether a watch rebuild waits for the files that turn on a change. | | [`resourceQueryExclude`](#resourcequeryexclude) | Shared | Which module queries are left out. | | [`threads`](#threads) | Shared | How wide the work is spread. | | [`configType`, `eslintPath`](#eslint) | Check | ESLint's own. | @@ -366,37 +365,6 @@ and a check configured with a function, such as a formatter written in the configuration, is run on webpack's thread, because a function cannot be handed to a worker. -#### `lintDependentsAfterBuild` - -- Type: - -```ts -type lintDependentsAfterBuild = boolean; -``` - -- Default: `false` - -Whether a watch rebuild waits for the files it did not rebuild but has to lint -again — for a check that [reads across files](#readsacrossfiles), the ones -importing a file that changed, however indirectly. Set, the build lints what it rebuilt and reports those from -their last run, and they are linted again once the build is over. When that -changes what the check reports, the new report is printed to the terminal, and -the next build carries it — so a dev server overlays it from then on rather than -at once. `fork-ts-checker-webpack-plugin`'s `async` makes the same trade. - -```js -new DiagnosticsPlugin({ - checks: [{ use: "eslint", lintDependentsAfterBuild: true }], -}); -``` - -Over three hundred modules under typed linting, an edit's rebuild took 191 ms -rather than 2.0 s, and an error it caused in a file importing the edited one was -printed 2.4 s after the edit. A build that is not watched, the first build of a -watch, and a check writing an [`outputReport`](#outputreport) lint everything -before they finish whatever this says — that file is written with the build, and -would otherwise hold what the build reported rather than what was found after. - #### `readsAcrossFiles` - Type: @@ -421,7 +389,7 @@ 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, or with TypeScript 7, -whose native compiler has no API to read imports through. There, +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. @@ -555,13 +523,7 @@ The work is moved out of the rebuild rather than off the machine: an edit arriving while such a check is still running waits for the rest of it, since a check holds the thread the build runs on. A report a newer rebuild has already overtaken is dropped, and a check never has two of its runs going at once — the -next starts when the last has reported. A TypeScript check on a worker of its -own (see [`threads`](#threads)) stops such a run as soon as the next rebuild -starts, between one file and the next: what the program got through is kept, -and the next run checks the rest. An edit made while a long run is going is -then reported once its own run is done, without waiting for the rest of the -old one. Over 600 files, an edit made while an earlier one was still being -checked was reported after 2.3–2.6 s instead of 4.2–4.5 s. +next starts when the last has reported. #### `ignoreDiagnostics` @@ -828,9 +790,9 @@ 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, one -run for each 500, 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 +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` — @@ -958,10 +920,12 @@ 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. +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 @@ -969,11 +933,8 @@ 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. +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` diff --git a/package-lock.json b/package-lock.json index f958e154..b3680598 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "schema-utils": "^4.8.0", "stylelint": "^17.15.0", "typescript": "^6.0.3", - "typescript7": "npm:typescript@^7.0.2", + "typescript7": "npm:typescript@^7.1.0-beta", "webpack": "^5.112.0" }, "engines": { @@ -55,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": { @@ -5908,9 +5908,9 @@ } }, "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==", + "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" ], @@ -5924,10 +5924,27 @@ "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.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "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" ], @@ -5942,9 +5959,9 @@ } }, "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==", + "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" ], @@ -5959,9 +5976,9 @@ } }, "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==", + "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" ], @@ -5976,9 +5993,9 @@ } }, "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==", + "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" ], @@ -5993,9 +6010,9 @@ } }, "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==", + "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" ], @@ -6010,9 +6027,9 @@ } }, "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==", + "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" ], @@ -6027,9 +6044,9 @@ } }, "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==", + "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" ], @@ -6044,9 +6061,9 @@ } }, "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==", + "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" ], @@ -6061,9 +6078,9 @@ } }, "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==", + "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" ], @@ -6078,9 +6095,9 @@ } }, "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==", + "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" ], @@ -6095,9 +6112,9 @@ } }, "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==", + "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" ], @@ -6112,9 +6129,9 @@ } }, "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==", + "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" ], @@ -6129,9 +6146,9 @@ } }, "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==", + "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" ], @@ -6146,9 +6163,9 @@ } }, "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==", + "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" ], @@ -6163,9 +6180,9 @@ } }, "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==", + "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" ], @@ -6180,9 +6197,9 @@ } }, "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==", + "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" ], @@ -6197,9 +6214,9 @@ } }, "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==", + "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" ], @@ -6214,9 +6231,9 @@ } }, "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==", + "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" ], @@ -6231,9 +6248,9 @@ } }, "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==", + "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" ], @@ -18512,9 +18529,9 @@ }, "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==", + "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": { @@ -18524,26 +18541,27 @@ "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" + "@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": { diff --git a/package.json b/package.json index 15cd9029..3f6b45c1 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "schema-utils": "^4.8.0", "stylelint": "^17.15.0", "typescript": "^6.0.3", - "typescript7": "npm:typescript@^7.0.2", + "typescript7": "npm:typescript@^7.1.0-beta", "webpack": "^5.112.0" }, "peerDependencies": { @@ -107,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 || ^7.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 12c4f62e..06601e07 100644 --- a/src/check.js +++ b/src/check.js @@ -24,12 +24,11 @@ import { toPosixPath } from "./utils.js"; /** @typedef {Dependencies & { errors?: DiagnosticError, warnings?: DiagnosticError, outputReport?: OutputReportContent }} Report */ /** @typedef {{ lint: (files: string[]) => void, keep: (files: string[]) => void, report: () => Promise, detach: (report: Promise) => void }} Runner */ /** @typedef {Map} ResultStore */ -/** @typedef {{ key: string, run: Promise, joined: boolean }} SharedRun */ /** * The runs of a compiler's checks left to finish on their own. * @param {Compilation} compilation compilation - * @returns {Map, cancel: () => void }>} each one's report, by check + * @returns {Map>} each one's report, by check */ function detachedReportsOf(compilation) { return keptFor(compilation.compiler, "detached-reports", () => new Map()); @@ -37,10 +36,8 @@ function detachedReportsOf(compilation) { // 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 -// belongs to the store of the compiler that made it. A run another compiler -// joined is one that compiler waits for too, so it is never stopped, and a run -// that is stopped is no longer there to join. -/** @type {Map} */ +// belongs to the store of the compiler that made it. +/** @type {Map>} */ const sharedRuns = new Map(); // What only decides which files are checked, or how what is found is reported. @@ -49,7 +46,6 @@ const NOT_SHARED = new Set([ "exclude", "ignoreDiagnostics", "extensions", - "lintDependentsAfterBuild", "files", "formatter", "outputReport", @@ -99,8 +95,6 @@ function getResultStore(compilation, check) { /** * The report of a run left to finish on its own, taken so that the next run of * the same check waits for it rather than driving a second tool beside it. - * Nothing prints a report of an older compilation, so a check that can stop - * one is told to. * @param {Compilation} compilation compilation * @param {string} check a key unique to the check within the compiler * @returns {Promise | undefined} the report still to land, if there is one @@ -111,11 +105,7 @@ function takeDetachedReport(compilation, check) { reports.delete(check); - if (!report) return undefined; - - report.cancel(); - - return report.settled; + return report; } /** @@ -245,9 +235,6 @@ function createCheckRunner(key, { id, name, adapter, options }, compilation) { const linted = new Set(); // A check that cannot lint fails the same way for every batch it is given. let failed = false; - // The runs this one started that another compiler could join. - /** @type {SharedRun[]} */ - const runs = []; /** * What the check answers with, and what it says it read to answer — taken @@ -261,11 +248,7 @@ function createCheckRunner(key, { id, name, adapter, options }, compilation) { const key = sharing && `${sharing}\0${files.toSorted().join("\0")}`; const running = key ? sharedRuns.get(key) : undefined; - if (running) { - running.joined = true; - - return running.run; - } + if (running) return running; const started = instance.lintFiles(files).then((results) => ({ results, @@ -277,10 +260,8 @@ function createCheckRunner(key, { id, name, adapter, options }, compilation) { if (key) { const forget = () => sharedRuns.delete(key); - const shared = { key, run: started, joined: false }; - sharedRuns.set(key, shared); - runs.push(shared); + sharedRuns.set(key, started); started.then(forget, forget); } @@ -495,25 +476,13 @@ function createCheckRunner(key, { id, name, adapter, options }, compilation) { function detach(report) { detached = true; - detachedReportsOf(compilation).set(checkKey, { - settled: report.then( + detachedReportsOf(compilation).set( + checkKey, + report.then( () => {}, () => {}, ), - cancel: () => { - if (runs.some((shared) => shared.joined)) return; - - for (const shared of runs) { - if (sharedRuns.get(shared.key) === shared) { - sharedRuns.delete(shared.key); - } - } - - pending.then((instance) => { - if (instance && instance.cancel) instance.cancel(); - }); - }, - }); + ); } return { detach, keep, lint, report }; diff --git a/src/checks/biome.check.js b/src/checks/biome.check.js index b6dfe438..ea07a632 100644 --- a/src/checks/biome.check.js +++ b/src/checks/biome.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=s;export default s;const e={type:"object",additionalProperties:!0,properties:{biomePath:{type:"string"},command:{enum:["lint","check"]},configFile:{type:"string"},args:{type:"array",items:{type:"string"}}},allOf:[{$ref:"../entry-options.json"}],$id:"biome.json"},r={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},lintDependentsAfterBuild:{type:"boolean"},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function t(e,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=e}={}){let a=null,l=0;if(l==l){if(!e||"object"!=typeof e||Array.isArray(e))return t.errors=[{params:{type:"object"}}],!1;if(void 0!==e.cache){if("boolean"!=typeof e.cache)return t.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==e.cacheLocation){if("string"!=typeof e.cacheLocation)return t.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.exclude){let r=e.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=e===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.extensions){let r=e.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=e===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.files){let r=e.files,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=e===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.fix){if("boolean"!=typeof e.fix)return t.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.formatter){let r=e.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let e=l;if(!(r instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=e===l,!(o=o||y)){let e=l;if(!(r instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=e===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.ignoreDiagnostics){let s=e.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(i=i||m)){let e=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(i=i||m)){let e=l;if(l===e)if(s&&"object"==typeof s&&!Array.isArray(s)){let e=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(e===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var g=e===l}else g=!0;if(g){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}g=e===l}else g=!0;if(g)if(void 0!==s.severity){let e=s.severity,t=l;if("error"!==e&&"warning"!==e){let e={params:{allowedValues:r.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[e]:a.push(e),l++}g=t===l}else g=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(i=i||m)){let e=l;if(l===e)if(Array.isArray(s)){let e=s.length;for(let t=0;t true, readsAcrossFiles: () => true, }; diff --git a/src/checks/eslint.check.js b/src/checks/eslint.check.js index e5d5a4fb..a64cd040 100644 --- a/src/checks/eslint.check.js +++ b/src/checks/eslint.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=t;export default t;const e={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},lintDependentsAfterBuild:{type:"boolean"},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function r(t,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=t}={}){let a=null,l=0;if(l==l){if(!t||"object"!=typeof t||Array.isArray(t))return r.errors=[{params:{type:"object"}}],!1;if(void 0!==t.cache){if("boolean"!=typeof t.cache)return r.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==t.cacheLocation){if("string"!=typeof t.cacheLocation)return r.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.exclude){let e=t.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=r===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.extensions){let e=t.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=r===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.files){let e=t.files,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=r===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.fix){if("boolean"!=typeof t.fix)return r.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.formatter){let e=t.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let r=l;if(!(e instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=r===l,!(o=o||y)){let r=l;if(!(e instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=r===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.ignoreDiagnostics){let s=t.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(i=i||m)){let r=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=r===l,!(i=i||m)){let r=l;if(l===r)if(s&&"object"==typeof s&&!Array.isArray(s)){let r=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(r===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var g=e===l}else g=!0;if(g){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}g=e===l}else g=!0;if(g)if(void 0!==s.severity){let r=s.severity,t=l;if("error"!==r&&"warning"!==r){let r={params:{allowedValues:e.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[r]:a.push(r),l++}g=t===l}else g=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=r===l,!(i=i||m)){let r=l;if(l===r)if(Array.isArray(s)){let r=s.length;for(let t=0;t boolean) => CheckResult[])=} filterResults keeps what a predicate accepts of every result, which is what `ignore` is applied through * @property {(formatter?: FormatterOption) => Promise} getFormatter loads a formatter, falling back to the tool's default one * @property {() => Promise} cleanup releases whatever the tool holds after a run - * @property {(() => void)=} cancel stops a run of an older compilation that is still going, which nothing reports any more; what it answers with is dropped */ /** diff --git a/src/checks/oxlint.check.js b/src/checks/oxlint.check.js index 7740743f..a42343f6 100644 --- a/src/checks/oxlint.check.js +++ b/src/checks/oxlint.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=t;export default t;const e={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},lintDependentsAfterBuild:{type:"boolean"},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function r(t,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=t}={}){let a=null,l=0;if(l==l){if(!t||"object"!=typeof t||Array.isArray(t))return r.errors=[{params:{type:"object"}}],!1;if(void 0!==t.cache){if("boolean"!=typeof t.cache)return r.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==t.cacheLocation){if("string"!=typeof t.cacheLocation)return r.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.exclude){let e=t.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=r===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.extensions){let e=t.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=r===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.files){let e=t.files,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let r=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=r===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.fix){if("boolean"!=typeof t.fix)return r.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==t.formatter){let e=t.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let r=l;if(!(e instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=r===l,!(o=o||y)){let r=l;if(!(e instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=r===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,r.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==t.ignoreDiagnostics){let s=t.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var g=f===l;if(!(i=i||g)){let r=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(g=r===l,!(i=i||g)){let r=l;if(l===r)if(s&&"object"==typeof s&&!Array.isArray(s)){let r=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(r===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var m=e===l}else m=!0;if(m){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}m=e===l}else m=!0;if(m)if(void 0!==s.severity){let r=s.severity,t=l;if("error"!==r&&"warning"!==r){let r={params:{allowedValues:e.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[r]:a.push(r),l++}m=t===l}else m=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(g=r===l,!(i=i||g)){let r=l;if(l===r)if(Array.isArray(s)){let r=s.length;for(let t=0;t /** @type {FileResult} */ (result).filename, - // Each run is a process of its own that reads the configuration and, for - // Biome, the project again: handed every file at once, a build starts one - // for each 500 files rather than one for each batch webpack builds. + // 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/stylelint.check.js b/src/checks/stylelint.check.js index 0ec726fb..ab2804dd 100644 --- a/src/checks/stylelint.check.js +++ b/src/checks/stylelint.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=r;export default r;const e={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},lintDependentsAfterBuild:{type:"boolean"},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function t(r,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=r}={}){let a=null,l=0;if(l==l){if(!r||"object"!=typeof r||Array.isArray(r))return t.errors=[{params:{type:"object"}}],!1;if(void 0!==r.cache){if("boolean"!=typeof r.cache)return t.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==r.cacheLocation){if("string"!=typeof r.cacheLocation)return t.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==r.exclude){let e=r.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let t=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=t===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==r.extensions){let e=r.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let t=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=t===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==r.files){let e=r.files,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let t=l;if(!Array.isArray(e)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=t===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==r.fix){if("boolean"!=typeof r.fix)return t.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==r.formatter){let e=r.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof e){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let t=l;if(!(e instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=t===l,!(o=o||y)){let t=l;if(!(e instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=t===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==r.ignoreDiagnostics){let s=r.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(i=i||m)){let t=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=t===l,!(i=i||m)){let t=l;if(l===t)if(s&&"object"==typeof s&&!Array.isArray(s)){let t=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let t={params:{additionalProperty:e}};null===a?a=[t]:a.push(t),l++;break}if(t===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var g=e===l}else g=!0;if(g){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}g=e===l}else g=!0;if(g)if(void 0!==s.severity){let t=s.severity,r=l;if("error"!==t&&"warning"!==t){let t={params:{allowedValues:e.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[t]:a.push(t),l++}g=r===l}else g=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=t===l,!(i=i||m)){let t=l;if(l===t)if(Array.isArray(s)){let t=s.length;for(let r=0;r, turn: Promise }} Native + * @typedef {{ loaded: EXPECTED_ANY, api: EXPECTED_ANY, snapshot: EXPECTED_ANY, files: Set, turn: Promise }} Native */ const nodeRequire = createRequire(import.meta.url); /** - * 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. + * 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 this is TypeScript 7 or later + * @returns {Promise} the native API, if the package ships one */ async function loadNative(specifier) { let manifest; @@ -42,12 +43,12 @@ async function loadNative(specifier) { return undefined; } - const { version } = JSON.parse(readFileSync(manifest, "utf8")); + const { name, exports } = JSON.parse(readFileSync(manifest, "utf8")); - if (Number(String(version).split(".")[0]) < 7) return undefined; + if (!exports || !exports["./async"]) return undefined; // The package names itself, so its own exports resolve from inside it. - const api = createRequire(manifest).resolve("typescript/unstable/async"); + const api = createRequire(manifest).resolve(`${name}/async`); return import(pathToFileURL(api).href); } @@ -105,7 +106,7 @@ function findConfigFile(options) { /** * @param {NativeDiagnostic} diagnostic one of what a run found - * @returns {string} its message with every message it chains, as `tsc` joins them + * @returns {string} its message with every message it chains */ function flatten(diagnostic) { return [ @@ -114,41 +115,6 @@ function flatten(diagnostic) { ].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 {number} error what the native compiler numbers an error - * @param {string} context what the file is named relative to - * @param {Map} texts the text each file was checked as - * @returns {string} the diagnostic, formatted - */ -function format(diagnostic, error, 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`; - - const text = texts.get(diagnostic.fileName) || ""; - - 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` - ); -} - /** * 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 @@ -167,7 +133,7 @@ function keyOf(file) { * 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 + * @returns {EXPECTED_ANY} the changes, in the shape `fileNotifications` takes */ function changesSince(native, compiler) { /** @type {string[]} */ @@ -210,23 +176,22 @@ async function checkOnce(native, options, compiler) { ); } - 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] }, - ); + 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.getProject(configFile); - const { program } = project; + const project = native.snapshot.getConfiguredProject(configFile); + const { program, parsedCommandLine } = project; const kinds = { syntactic: true, semantic: true, @@ -234,7 +199,7 @@ async function checkOnce(native, options, compiler) { global: true, ...options.diagnosticOptions, }; - const { compilerOptions } = project; + const compilerOptions = parsedCommandLine.options; /** @type {NativeDiagnostic[][]} */ const found = await Promise.all([ program.getConfigFileParsingDiagnostics(), @@ -247,27 +212,8 @@ async function checkOnce(native, options, compiler) { : [], kinds.semantic ? program.getSemanticDiagnostics() : [], ]); - // The text each file was checked as, which is what the line under a - // diagnostic is printed from, rather than what is on disk by now. - const named = new Set( - found - .flat() - .flatMap((diagnostic) => - diagnostic.fileName ? [diagnostic.fileName] : [], - ), - ); - /** @type {Map} */ - const texts = new Map( - await Promise.all( - [...named].map( - async (fileName) => - /** @type {[string, string]} */ ([ - fileName, - ((await program.getSourceFile(fileName)) || { text: "" }).text, - ]), - ), - ), - ); + const { DiagnosticCategory, formatDiagnosticsWithColorAndContext } = + native.loaded; /** @type {Set} */ const seen = new Set(); /** @type {Described[]} */ @@ -275,7 +221,7 @@ async function checkOnce(native, options, compiler) { 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}`; + const key = JSON.stringify(diagnostic); if (seen.has(key)) continue; @@ -285,9 +231,10 @@ async function checkOnce(native, options, compiler) { // 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 === native.error ? "error" : "warning", + severity: + diagnostic.category === DiagnosticCategory.Error ? "error" : "warning", text: flatten(diagnostic), - formatted: format(diagnostic, native.error, context, texts), + formatted: formatDiagnosticsWithColorAndContext([diagnostic], native.api), }); } @@ -297,7 +244,7 @@ async function checkOnce(native, options, compiler) { 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], + files: [configFile, ...parsedCommandLine.fileNames], // Where a file the program has never held can appear. directories: [dirname(configFile)], }; @@ -332,8 +279,8 @@ function checkNative(native, options, compiler) { */ function startNative(loaded, options) { return { + loaded, api: new loaded.API({ cwd: String(options.context) }), - error: loaded.DiagnosticCategory.Error, snapshot: undefined, files: new Set(), turn: Promise.resolve(), diff --git a/src/checks/typescript-program.js b/src/checks/typescript-program.js index 51354428..b3a4f00f 100644 --- a/src/checks/typescript-program.js +++ b/src/checks/typescript-program.js @@ -18,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[], extensions: string[], cancelled?: boolean }} Found + * @typedef {{ diagnostics: Diagnostic[], host: EXPECTED_ANY, files: string[], directories: string[], writes: string[], extensions: string[] }} Found */ /** @@ -443,10 +443,9 @@ function stateFileFor(options, configFile, compilerOptions) { * every rebuild. * @param {Held} held what the last compilation left behind, with what its declarations said * @param {EXPECTED_ANY} program the program built this time - * @param {EXPECTED_ANY=} cancellation what tells a run nobody waits for any more to stop * @returns {Diagnostic[]} what the declarations of all its files say */ -function declarationDiagnostics(held, program, cancellation) { +function declarationDiagnostics(held, program) { // Asked before the semantic diagnostics are: once those are known the // builder has nothing left that it would check again. let reached = held.declarations.size === 0; @@ -454,10 +453,9 @@ function declarationDiagnostics(held, program, cancellation) { const affected = new Set(); for ( - let next = - held.program.getSemanticDiagnosticsOfNextAffectedFile(cancellation); + let next = held.program.getSemanticDiagnosticsOfNextAffectedFile(); next; - next = held.program.getSemanticDiagnosticsOfNextAffectedFile(cancellation) + next = held.program.getSemanticDiagnosticsOfNextAffectedFile() ) { // The whole program, where a change reaches every file. if (next.affected === program) reached = true; @@ -477,7 +475,7 @@ function declarationDiagnostics(held, program, cancellation) { file.fileName, !reached && known && !affected.has(file.fileName) ? known - : program.getDeclarationDiagnostics(file, cancellation), + : program.getDeclarationDiagnostics(file), ); } @@ -493,10 +491,17 @@ function declarationDiagnostics(held, program, cancellation) { * @param {TypeScript} ts the loaded TypeScript * @param {Options} options options * @param {Held} held what the last compilation left behind - * @param {EXPECTED_ANY=} cancellation what tells a run nobody waits for any more to stop * @returns {Found} what it found, and what it read to find it */ -function check(ts, options, held, cancellation) { +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 || @@ -644,46 +649,20 @@ function check(ts, options, held, cancellation) { ] .filter(Boolean) .map(String); - /** @type {Diagnostic[]} */ - let diagnostics; - - try { - diagnostics = [ - ...unrecoverable, - ...ts.sortAndDeduplicateDiagnostics([ - ...program.getConfigFileParsingDiagnostics(), - ...program.getOptionsDiagnostics(), - ...(kinds.syntactic - ? held.program.getSyntacticDiagnostics(undefined, cancellation) - : []), - ...(kinds.global ? program.getGlobalDiagnostics(cancellation) : []), - ...(kinds.declaration && - (parsed.options.declaration || parsed.options.composite) - ? declarationDiagnostics(held, program, cancellation) - : []), - ...(kinds.semantic - ? held.program.getSemanticDiagnostics(undefined, cancellation) - : []), - ]), - ]; - } catch (err) { - if (!(err instanceof ts.OperationCanceledException)) throw err; - - // The builder keeps what it got through and checks the rest next time. - // Which files the declarations were drained of is lost with the run, so - // the next one works all of them out again. - held.declarations = new Map(); - - return { - cancelled: true, - diagnostics: [], - host, - files, - directories, - writes, - extensions, - }; - } + const diagnostics = [ + ...unrecoverable, + ...ts.sortAndDeduplicateDiagnostics([ + ...program.getConfigFileParsingDiagnostics(), + ...program.getOptionsDiagnostics(), + ...(kinds.syntactic ? held.program.getSyntacticDiagnostics() : []), + ...(kinds.global ? program.getGlobalDiagnostics() : []), + ...(kinds.declaration && + (parsed.options.declaration || parsed.options.composite) + ? declarationDiagnostics(held, program) + : []), + ...(kinds.semantic ? held.program.getSemanticDiagnostics() : []), + ]), + ]; if (stateFile) { try { diff --git a/src/checks/typescript-worker.js b/src/checks/typescript-worker.js index fa3d3a66..f370bee1 100644 --- a/src/checks/typescript-worker.js +++ b/src/checks/typescript-worker.js @@ -45,23 +45,12 @@ function setup(options) { /** * Runs the check and answers with what crosses back: a `Diagnostic` holds the * source file it was found in, so each one is described and formatted here. - * @param {string[]} files nothing: the program is the config file's - * @param {Int32Array} stopped set once nobody waits for this run, shared with the plugin * @returns {Promise} what it found, and what it read to find it */ -async function lintFiles(files, stopped) { +async function lintFiles() { const ts = await getTypeScript(); const held = getHeld(compiler, "worker"); - // TypeScript asks between one file and the next, which is where a run - // nobody waits for any more stops. - const requested = () => Atomics.load(stopped, 0) === 1; - const cancellation = { - isCancellationRequested: requested, - throwIfCancellationRequested() { - if (requested()) throw new ts.OperationCanceledException(); - }, - }; - const found = check(ts, checkOptions, held, cancellation); + const found = check(ts, checkOptions, held); // Each on its own, so that what is left after `ignoreDiagnostics` can be // printed without asking the worker again. Each block carries the newline // TypeScript ends it with, which is what separates one from the next, so @@ -76,7 +65,6 @@ async function lintFiles(files, stopped) { return { diagnostics, - cancelled: Boolean(found.cancelled), files: found.files, directories: found.directories, writes: found.writes, diff --git a/src/checks/typescript.check.js b/src/checks/typescript.check.js index dc48d458..bc21ae97 100644 --- a/src/checks/typescript.check.js +++ b/src/checks/typescript.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=s;export default s;const e={type:"object",additionalProperties:!0,properties:{typescriptPath:{type:"string"},configFile:{type:"string"},compilerOptions:{type:"object"},build:{type:"boolean"},mode:{enum:["readonly","write-tsbuildinfo","write-dts","write-references"]},diagnosticOptions:{type:"object",additionalProperties:!1,properties:{syntactic:{type:"boolean"},semantic:{type:"boolean"},declaration:{type:"boolean"},global:{type:"boolean"}}}},allOf:[{$ref:"../entry-options.json"}],$id:"typescript.json"},r={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},lintDependentsAfterBuild:{type:"boolean"},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function t(e,{instancePath:s="",parentData:n,parentDataProperty:o,rootData:i=e}={}){let a=null,l=0;if(l==l){if(!e||"object"!=typeof e||Array.isArray(e))return t.errors=[{params:{type:"object"}}],!1;if(void 0!==e.cache){if("boolean"!=typeof e.cache)return t.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==e.cacheLocation){if("string"!=typeof e.cacheLocation)return t.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.exclude){let r=e.exclude,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=i===l;if(!(o=o||f)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=e===l,o=o||f}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.extensions){let r=e.extensions,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=i===l;if(!(o=o||c)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=e===l,o=o||c}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.files){let r=e.files,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=i===l;if(!(o=o||u)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=e===l,o=o||u}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.fix){if("boolean"!=typeof e.fix)return t.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.formatter){let r=e.formatter,s=l,n=l,o=!1,i=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=i===l;if(!(o=o||y)){let e=l;if(!(r instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=e===l,!(o=o||y)){let e=l;if(!(r instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=e===l,o=o||y}}if(!o){let e={params:{}};return null===a?a=[e]:a.push(e),l++,t.errors=a,!1}l=n,null!==a&&(n?a.length=n:a=null),p=s===l}else p=!0;if(p){if(void 0!==e.ignoreDiagnostics){let s=e.ignoreDiagnostics,n=l,o=l,i=!1,f=l;if("number"!=typeof s){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(i=i||m)){let e=l;if("string"!=typeof s){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(i=i||m)){let e=l;if(l===e)if(s&&"object"==typeof s&&!Array.isArray(s)){let e=l;for(let e in s)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(e===l){if(void 0!==s.file){let e=l;if("string"!=typeof s.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var d=e===l}else d=!0;if(d){if(void 0!==s.code){let e=l;if("string"!=typeof s.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}d=e===l}else d=!0;if(d)if(void 0!==s.severity){let e=s.severity,t=l;if("error"!==e&&"warning"!==e){let e={params:{allowedValues:r.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[e]:a.push(e),l++}d=t===l}else d=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(i=i||m)){let e=l;if(l===e)if(Array.isArray(s)){let e=s.length;for(let t=0;t | undefined} */ - let answer; /** * @param {EXPECTED_ANY} one a diagnostic, or what a worker said of one @@ -278,22 +274,7 @@ async function create({ key, id: entry, options, compilation }) { } if (worker) { - answer = worker.lintFiles(); - - const found = await answer; - - // A stopped run found nothing a later rebuild can be answered with. - if (found.cancelled) { - runs.delete(id); - ({ read, directories, writes, missing } = { - read: found.files, - directories: found.directories, - writes: found.writes, - missing: found.missing, - }); - - return []; - } + const found = await worker.lintFiles(); return remember( { @@ -324,11 +305,6 @@ async function create({ key, id: entry, options, compilation }) { [...held.seen], ); }, - // A run of an older compilation is left to finish on the worker while - // webpack builds the next one, which waits for it before checking again. - cancel() { - if (worker && answer) worker.cancel(answer); - }, readFiles() { return read; }, diff --git a/src/index.js b/src/index.js index a47f3381..3767b890 100644 --- a/src/index.js +++ b/src/index.js @@ -147,8 +147,8 @@ async function affectedFiles(compiler, check, files) { * Walks the file system for the files a check wants, and says which of them * webpack has just seen change. * @param {Compiler} compiler compiler - * @param {ResolvedCheck & { afterwards?: string[] }} check the check to collect the files of - * @returns {Promise<{ lint: string[], keep: string[], after?: string[] }>} the files to lint, the ones to report from the last compilation, and of those the ones to lint again once the build is over + * @param {ResolvedCheck} check the check to collect the files of + * @returns {Promise<{ lint: string[], keep: string[] }>} the files to lint, and the ones to report from the last compilation */ async function collectFromFileSystem(compiler, check) { const { adapter, wanted, exclude } = check; @@ -170,39 +170,13 @@ async function collectFromFileSystem(compiler, check) { if (readsAcrossFiles(compiler, check)) { const affected = await affectedFiles(compiler, check, found); - if (!check.afterwards) { - if (!affected) return { lint: found, keep: [] }; + if (!affected) return { lint: found, keep: [] }; - /** @type {{ lint: string[], keep: string[] }} */ - const split = { lint: [], keep: [] }; - - for (const file of found) { - split[affected.has(toPosixPath(file)) ? "lint" : "keep"].push(file); - } - - return split; - } - - // Told to look again once the build is over: what changed is linted with - // the build, and what else the change reaches is reported from its last - // run until it is linted after it. - const touched = new Set( - [...modifiedFiles].map((file) => toPosixPath(file)), - ); - /** @type {{ lint: string[], keep: string[], after: string[] }} */ - const split = { lint: [], keep: [], after: [] }; + /** @type {{ lint: string[], keep: string[] }} */ + const split = { lint: [], keep: [] }; for (const file of found) { - const known = toPosixPath(file); - - if (touched.has(known)) { - split.lint.push(file); - continue; - } - - split.keep.push(file); - - if (!affected || affected.has(known)) split.after.push(file); + split[affected.has(toPosixPath(file)) ? "lint" : "keep"].push(file); } return split; @@ -306,14 +280,6 @@ function isAdvisory(options) { }); } -/** - * @param {{ errors?: DiagnosticError, warnings?: DiagnosticError }} report what a run of a check reported - * @returns {string} what it says, to tell two of them apart by - */ -function describeReport({ errors, warnings }) { - return `${errors ? errors.message : ""}\0${warnings ? warnings.message : ""}`; -} - class DiagnosticsWebpackPlugin { /** * @param {Options} options options @@ -491,46 +457,6 @@ class DiagnosticsWebpackPlugin { ); }; - /** - * Lints what a rebuild reported from the last run once the build is over, - * and prints what that changes. What it finds is remembered either way, so - * the next build reports it. - * @param {{ files: string[], afterwards?: string[], compilation: Compilation } & ResolvedCheck} check the check the build ran - * @param {string} reported what the build reported of it - * @param {number} mine which compilation it was run for - */ - const lintAfterBuild = (check, reported, mine) => { - const after = new Set(check.afterwards); - const runner = this.createRunner(check, check.compilation); - - runner.lint([...after]); - runner.keep(check.files.filter((file) => !after.has(file))); - - const report = runner.report(); - - runner.detach(report); - - report.then( - (found) => { - if (generation !== mine || describeReport(found) === reported) { - return; - } - - const logger = compiler.getInfrastructureLogger(LINT_PLUGIN); - - logger.info( - `${check.name}: what the last build reported has changed now that the files it did not rebuild are linted again`, - ); - - if (found.errors) logger.error(found.errors.message); - if (found.warnings) logger.warn(found.warnings.message); - }, - (err) => { - compiler.getInfrastructureLogger(LINT_PLUGIN).error(err.message); - }, - ); - }; - compiler.hooks.compilation.tap(this.key, (compilation) => { generation += 1; @@ -562,22 +488,6 @@ class DiagnosticsWebpackPlugin { // the graph is done and the files a change can reach are known. /** @type {string[]} */ const deferred = []; - // A rebuild of a check reading across files that is told to lint what - // a change reaches once the build is over: those files, reported from - // their last run until then. Nothing when it lints them with the build, - // which one writing an `outputReport` does: that file is written once, - // with every check sharing it, and what comes after would not reach it. - const afterwards = - !late && - check.options.lintDependentsAfterBuild && - !( - check.options.outputReport && check.options.outputReport.filePath - ) && - compiler.watchMode && - !compilation.compiler.isChild() && - compiler.modifiedFiles - ? /** @type {string[]} */ ([]) - : undefined; let scheduled = false; // Handed in batches while webpack builds, unless the check's tool // pays for a run of its own every time it is handed some. @@ -613,7 +523,6 @@ class DiagnosticsWebpackPlugin { return { ...check, - compilation, /** @type {string[]} */ files: [], // The same files, to tell one already listed without a search. @@ -622,7 +531,6 @@ class DiagnosticsWebpackPlugin { pending, kept, deferred, - afterwards, flush, handOver, late, @@ -698,12 +606,8 @@ class DiagnosticsWebpackPlugin { check.collecting = collectFromFileSystem(compiler, check).then( (collected) => { - check.files.push(...collected.lint, ...collected.keep); check.pending.push(...collected.lint); check.kept.push(...collected.keep); - if (collected.after && check.afterwards) { - check.afterwards.push(...collected.after); - } check.flush(true); }, (err) => { @@ -725,16 +629,10 @@ class DiagnosticsWebpackPlugin { ); for (const file of check.deferred.splice(0)) { - if (affected && !affected.has(toPosixPath(file))) { - check.kept.push(file); - } else if (check.afterwards) { - // Reported from its last run now, and linted again once - // the build is over. - check.kept.push(file); - check.afterwards.push(file); - } else { - check.pending.push(file); - } + (!affected || affected.has(toPosixPath(file)) + ? check.pending + : check.kept + ).push(file); } } @@ -839,12 +737,6 @@ class DiagnosticsWebpackPlugin { readLast.set(id, { read, missing, directories, writes }); await watch(check, report); - if (check.afterwards && check.afterwards.length > 0) { - const reported = describeReport(report); - - afterBuild.push(() => lintAfterBuild(check, reported, mine)); - } - // `reportAs` has already dropped whatever it reports as `false`, // so what is left only needs putting where it belongs. for (const [results, reported] of /** @type {const} */ ([ diff --git a/src/options.check.js b/src/options.check.js index 2a3ef85f..f9726a81 100644 --- a/src/options.check.js +++ b/src/options.check.js @@ -4,4 +4,4 @@ * This file was automatically generated by "schema-utils". * DO NOT MODIFY BY HAND. */ -export const validate=a;export default a;const e={type:"object",additionalProperties:!1,properties:{context:{type:"string"},lintOnStart:{type:"boolean"},checks:{type:"array",minItems:1,items:{anyOf:[{type:"string"},{$ref:"./entry-options.json"}]}},cache:{$ref:"./shared-options.json#/properties/cache"},cacheLocation:{$ref:"./shared-options.json#/properties/cacheLocation"},exclude:{$ref:"./shared-options.json#/properties/exclude"},extensions:{$ref:"./shared-options.json#/properties/extensions"},files:{$ref:"./shared-options.json#/properties/files"},fix:{$ref:"./shared-options.json#/properties/fix"},formatter:{$ref:"./shared-options.json#/properties/formatter"},ignoreDiagnostics:{$ref:"./shared-options.json#/properties/ignoreDiagnostics"},lintDependentsAfterBuild:{$ref:"./shared-options.json#/properties/lintDependentsAfterBuild"},outputReport:{$ref:"./shared-options.json#/properties/outputReport"},readsAcrossFiles:{$ref:"./shared-options.json#/properties/readsAcrossFiles"},reportAs:{$ref:"./shared-options.json#/properties/reportAs"},resourceQueryExclude:{$ref:"./shared-options.json#/properties/resourceQueryExclude"},threads:{$ref:"./shared-options.json#/properties/threads"}},required:["checks"],$id:"options.json"},r={anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},t={anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},s={anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]},n=Object.prototype.hasOwnProperty,o={type:"object",additionalProperties:!0,properties:{cache:{type:"boolean"},cacheLocation:{type:"string"},exclude:{anyOf:[{type:"string"},{type:"array"}]},extensions:{anyOf:[{type:"string"},{type:"array"}]},files:{anyOf:[{type:"string"},{type:"array"}]},fix:{type:"boolean"},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]},ignoreDiagnostics:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}},{type:"array",items:{anyOf:[{type:"number"},{type:"string"},{type:"object",additionalProperties:!1,properties:{file:{type:"string"},code:{type:"string"},severity:{enum:["error","warning"]}}}]}},{instanceof:"Function"}]},lintDependentsAfterBuild:{type:"boolean"},outputReport:{anyOf:[{type:"boolean"},{type:"object",additionalProperties:!1,properties:{filePath:{anyOf:[{type:"string"}]},formatter:{anyOf:[{type:"string"},{instanceof:"Function"},{instanceof:"Promise"}]}}}]},readsAcrossFiles:{type:"boolean"},reportAs:{anyOf:[{enum:["error","warning","log",!1]},{type:"object",additionalProperties:!1,properties:{errors:{enum:["error","warning","log",!1]},warnings:{enum:["error","warning","log",!1]}}}]},resourceQueryExclude:{anyOf:[{instanceof:"RegExp"},{type:"string"},{type:"array",items:{anyOf:[{instanceof:"RegExp"},{type:"string"}]}}]},threads:{anyOf:[{enum:["auto"]},{type:"number"},{type:"boolean"}]}}};function i(e,{instancePath:r="",parentData:t,parentDataProperty:s,rootData:n=e}={}){let a=null,l=0;if(l==l){if(!e||"object"!=typeof e||Array.isArray(e))return i.errors=[{params:{type:"object"}}],!1;if(void 0!==e.cache){if("boolean"!=typeof e.cache)return i.errors=[{params:{type:"boolean"}}],!1;var p=l===l}else p=!0;if(p){if(void 0!==e.cacheLocation){if("string"!=typeof e.cacheLocation)return i.errors=[{params:{type:"string"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.exclude){let r=e.exclude,t=l,s=l,n=!1,o=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var f=o===l;if(!(n=n||f)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}f=e===l,n=n||f}if(!n){let e={params:{}};return null===a?a=[e]:a.push(e),l++,i.errors=a,!1}l=s,null!==a&&(s?a.length=s:a=null),p=t===l}else p=!0;if(p){if(void 0!==e.extensions){let r=e.extensions,t=l,s=l,n=!1,o=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var u=o===l;if(!(n=n||u)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}u=e===l,n=n||u}if(!n){let e={params:{}};return null===a?a=[e]:a.push(e),l++,i.errors=a,!1}l=s,null!==a&&(s?a.length=s:a=null),p=t===l}else p=!0;if(p){if(void 0!==e.files){let r=e.files,t=l,s=l,n=!1,o=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var c=o===l;if(!(n=n||c)){let e=l;if(!Array.isArray(r)){let e={params:{type:"array"}};null===a?a=[e]:a.push(e),l++}c=e===l,n=n||c}if(!n){let e={params:{}};return null===a?a=[e]:a.push(e),l++,i.errors=a,!1}l=s,null!==a&&(s?a.length=s:a=null),p=t===l}else p=!0;if(p){if(void 0!==e.fix){if("boolean"!=typeof e.fix)return i.errors=[{params:{type:"boolean"}}],!1;p=l===l}else p=!0;if(p){if(void 0!==e.formatter){let r=e.formatter,t=l,s=l,n=!1,o=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var y=o===l;if(!(n=n||y)){let e=l;if(!(r instanceof Function)){let e={params:{}};null===a?a=[e]:a.push(e),l++}if(y=e===l,!(n=n||y)){let e=l;if(!(r instanceof Promise)){let e={params:{}};null===a?a=[e]:a.push(e),l++}y=e===l,n=n||y}}if(!n){let e={params:{}};return null===a?a=[e]:a.push(e),l++,i.errors=a,!1}l=s,null!==a&&(s?a.length=s:a=null),p=t===l}else p=!0;if(p){if(void 0!==e.ignoreDiagnostics){let r=e.ignoreDiagnostics,t=l,s=l,n=!1,f=l;if("number"!=typeof r){let e={params:{type:"number"}};null===a?a=[e]:a.push(e),l++}var m=f===l;if(!(n=n||m)){let e=l;if("string"!=typeof r){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(n=n||m)){let e=l;if(l===e)if(r&&"object"==typeof r&&!Array.isArray(r)){let e=l;for(let e in r)if("file"!==e&&"code"!==e&&"severity"!==e){let r={params:{additionalProperty:e}};null===a?a=[r]:a.push(r),l++;break}if(e===l){if(void 0!==r.file){let e=l;if("string"!=typeof r.file){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}var h=e===l}else h=!0;if(h){if(void 0!==r.code){let e=l;if("string"!=typeof r.code){let e={params:{type:"string"}};null===a?a=[e]:a.push(e),l++}h=e===l}else h=!0;if(h)if(void 0!==r.severity){let e=r.severity,t=l;if("error"!==e&&"warning"!==e){let e={params:{allowedValues:o.properties.ignoreDiagnostics.anyOf[2].properties.severity.enum}};null===a?a=[e]:a.push(e),l++}h=t===l}else h=!0}}}else{let e={params:{type:"object"}};null===a?a=[e]:a.push(e),l++}if(m=e===l,!(n=n||m)){let e=l;if(l===e)if(Array.isArray(r)){let e=r.length;for(let t=0;t Promise} LintTask */ /** @typedef {JestWorker & { lintFiles: LintTask }} Worker */ /** @typedef {{ lintFiles: LintTask, end: () => Promise }} Pool */ -/** @typedef {{ lintFiles: () => Promise, cancel: (answer: Promise) => void, end: () => Promise }} Solo */ +/** @typedef {{ lintFiles: () => Promise, end: () => Promise }} Solo */ /** * How many threads the user asked for, as a count. A check is spread over one @@ -107,13 +107,6 @@ function createPool(source, size, setupArgs) { function createSolo(source, setupArgs) { if (!isTransferable(setupArgs)) return null; - // Each run is handed a flag it shares with the worker rather than one sent - // to it later: a message would wait behind the run it is meant to stop. A - // worker can be shared by checks of several compilers, so each run has a - // flag of its own and stopping one leaves the rest. - /** @type {WeakMap, Int32Array>} */ - const flags = new WeakMap(); - let worker = /** @type {Worker | null} */ ( new JestWorker(source, { enableWorkerThreads: true, @@ -123,26 +116,11 @@ function createSolo(source, setupArgs) { ); return { - // Not async: the promise handed back is the one `cancel` is given. - lintFiles: () => { + lintFiles: async () => { /* istanbul ignore next */ - if (!worker) return Promise.resolve(undefined); - - const stopped = new Int32Array(new SharedArrayBuffer(4)); - const answer = /** @type {EXPECTED_ANY} */ (worker).lintFiles( - [], - stopped, - ); - - flags.set(answer, stopped); - - return answer; - }, - // Whether the run has started or is still queued. - cancel: (answer) => { - const stopped = flags.get(answer); + if (!worker) return undefined; - if (stopped) Atomics.store(stopped, 0, 1); + return /** @type {Worker} */ (worker).lintFiles([]); }, end: async () => { /* istanbul ignore else */ diff --git a/test/after-build.test.js b/test/after-build.test.js deleted file mode 100644 index ae13a079..00000000 --- a/test/after-build.test.js +++ /dev/null @@ -1,200 +0,0 @@ -// eslint-disable-next-line jsdoc/reject-any-type -/** @typedef {any} EXPECTED_ANY */ - -import assert from "node:assert/strict"; -import { rmSync, utimesSync, writeFileSync } from "node:fs"; -import { join, normalize } from "node:path"; -import { afterEach, beforeEach, describe, it } from "node:test"; - -import webpack from "webpack"; - -import DiagnosticsPlugin from "../src/index.js"; - -const fixtures = join(import.meta.dirname, "fixtures"); -const entry = join(fixtures, "after-entry.js"); -const leaf = join(fixtures, "after-leaf.js"); - -// What the check says of the entry, which an edit to the leaf changes: a typed -// rule reading the leaf through the entry's import. -let generation = 0; -let failFormatting = false; - -const adapter = { - name: "across", - resultPath: (/** @type {{ file: string }} */ result) => result.file, - readsAcrossFiles: () => true, - create: async () => ({ - lintFiles: async (/** @type {string[]} */ files) => - files - // A glob hands over `/` where `join` writes `\` on Windows. - .filter((file) => normalize(file) === entry) - .map((file) => ({ file, text: `found ${generation}` })), - getResults: async (/** @type {unknown[]} */ results) => results, - splitResults: (/** @type {unknown[]} */ results) => ({ - errors: results, - warnings: [], - }), - getFormatter: async () => { - if (failFormatting) throw new Error("cannot format"); - - return async (/** @type {{ text: string }[]} */ results) => - results.map((result) => result.text).join("\n"); - }, - cleanup: async () => {}, - }), -}; - -describe("lintDependentsAfterBuild", () => { - let watching; - /** @type {string[]} */ - let logged; - - beforeEach(() => { - generation = 0; - failFormatting = false; - logged = []; - writeFileSync(leaf, "module.exports = 1;\n"); - writeFileSync(entry, "require('./after-leaf');\n"); - - // Written well before the watch starts, or its first rebuild counts them - // as changed along with the edit. - const past = new Date(Date.now() - 60_000); - - for (const file of [entry, leaf]) utimesSync(file, past, past); - }); - - afterEach(async () => { - // Closed before the files go, so no watch of this test sees the next one. - if (watching) { - await new Promise((resolve) => { - watching.close(resolve); - }); - } - watching = undefined; - rmSync(entry, { force: true }); - rmSync(leaf, { force: true }); - }); - - /** - * @param {object} check the entry of `checks` - * @param {(log: string, stats: import("webpack").Stats) => boolean} until when the log says what the test waits for - * @param {() => void} done done - * @param {(stats: import("webpack").Stats) => void=} rebuilt what to do once a rebuild is over - */ - const run = (check, until, done, rebuilt = () => {}) => { - /** @type {import("webpack").Stats | undefined} */ - let stats; - const compiler = webpack({ - context: fixtures, - entry: "./after-entry.js", - mode: "development", - output: { path: join(import.meta.dirname, "outputs") }, - infrastructureLogging: { - level: "info", - stream: /** @type {EXPECTED_ANY} */ ({ - isTTY: false, - write: (/** @type {string} */ text) => { - logged.push(text); - - if (until(logged.join(""), stats)) done(); - }, - }), - }, - plugins: [ - new DiagnosticsPlugin({ - checks: [{ use: adapter, lintDependentsAfterBuild: true, ...check }], - }), - ], - }); - let first = true; - - watching = compiler.watch({}, (err, current) => { - assert.strictEqual(err, null); - stats = current; - - if (!first) { - rebuilt(current); - - return; - } - - first = false; - generation = 1; - writeFileSync(leaf, "module.exports = 2;\n"); - }); - }; - - it("should print what changes once the rest is linted, and carry it into the next build", (t, done) => { - let printed = false; - - run( - {}, - (log, stats) => { - if (printed || !/found 1/u.test(log)) return false; - - printed = true; - // The build itself reported the entry from its last run. - assert.match(stats.compilation.errors[0].message, /found 0/u); - assert.match(log, /what the last build reported has changed/u); - - // The next build reports what was found after this one. - writeFileSync(leaf, "module.exports = 3;\n"); - - return false; - }, - done, - (stats) => { - if (!printed) return; - - assert.match(stats.compilation.errors[0].message, /found 1/u); - done(); - }, - ); - }); - - it("should lint everything with the build for a check writing an output report", (t, done) => { - run( - { outputReport: { filePath: "after-report.txt" } }, - () => false, - done, - (stats) => { - // Nothing waits for after the build: the entry is reported as found. - assert.match(stats.compilation.errors[0].message, /found 1/u); - done(); - }, - ); - }); - - it("should do the same for a check that globs its files", (t, done) => { - run( - { files: ["after-entry.js", "after-leaf.js"] }, - (log) => /found 1/u.test(log), - done, - ); - }); - - it("should do the same for a check told it reads across files", (t, done) => { - // An adapter that says nothing of it, answered for by the option. - const { readsAcrossFiles, ...silent } = adapter; - - assert.strictEqual(typeof readsAcrossFiles, "function"); - run( - { use: silent, readsAcrossFiles: true }, - (log) => /found 1/u.test(log), - done, - ); - }); - - it("should print why linting the rest failed", (t, done) => { - run( - {}, - (log) => /cannot format/u.test(log), - done, - () => { - // Formatting fails once the build is over, so only the run after it - // has nowhere to report but the terminal. - failFormatting = true; - }, - ); - }); -}); diff --git a/test/incremental.test.js b/test/incremental.test.js index de5dde43..d6cbaa8b 100644 --- a/test/incremental.test.js +++ b/test/incremental.test.js @@ -190,64 +190,6 @@ describe("incremental", () => { }); }; - /** - * Rebuilds after an edit to the leaf with `lintDependentsAfterBuild` set, - * and says what was linted during the build and after it. - * @param {object} options plugin options - * @param {() => void} done done - */ - const afterBuild = (options, done) => { - writeThree(); - - let first = true; - - watch = pack("watch", { - eslintPath, - lintDependentsAfterBuild: true, - ...options, - }).watch({}, (err) => { - assert.strictEqual(err, null); - - if (first) { - first = false; - require(eslintPath)._reset(); - require(eslintPath)._readAcrossFiles(true); - writeFileSync(leaf, "const leaf = 2;\n"); - - return; - } - - // The build waits only for what changed. - const during = linted(); - - assert.strictEqual(during.length, 1); - assert.match(during[0], /watch-leaf\.js/u); - - // What the change reaches is linted once the build is over, and what - // it does not reach is not linted at all. - setTimeout(() => { - rmSync(other, { force: true }); - - const after = linted(); - - assert.ok(after.some((file) => /watch-entry\.js/u.test(file))); - assert.ok(after.every((file) => !/watch-other\.js/u.test(file))); - done(); - }, 500); - }); - }; - - it("should lint after the build only what a change reaches among the modules when told to", (t, done) => { - afterBuild({}, done); - }); - - it("should lint after the build only what a change reaches among the files it globs when told to", (t, done) => { - afterBuild( - { files: ["watch-entry.js", "watch-leaf.js", "watch-other.js"] }, - done, - ); - }); - it("should lint again what a change reaches among the files it globs", (t, done) => { writeThree(); rebuild( @@ -261,68 +203,6 @@ describe("incremental", () => { }); }); - it("should lint what did not change after the build when told to", (t, done) => { - writeFileSync(leaf, "const leaf = 1;\n"); - writeFileSync(entry, "require('./watch-leaf');\nconst entry = 1;\n"); - require(eslintPath)._reset(); - require(eslintPath)._readAcrossFiles(true); - - // Written well before the watch starts, or its first rebuild counts them as - // changed along with the edit. - const past = new Date(Date.now() - 60_000); - - for (const file of [entry, leaf]) utimesSync(file, past, past); - - // eslint-disable-next-line no-use-before-define - let next = firstPass; - const compiler = pack("watch", { - eslintPath, - lintDependentsAfterBuild: true, - }); - - watch = compiler.watch({}, (err, stats) => next(err, stats)); - - function secondPass(err) { - assert.strictEqual(err, null); - - // The build waited only for what it rebuilt. - const during = linted(); - - assert.strictEqual(during.length, 1); - assert.match(during[0], /watch-leaf\.js/u); - - // The rest is linted once it is over. - setTimeout(() => { - assert.ok(linted().some((file) => /watch-entry\.js/u.test(file))); - done(); - }, 500); - } - - function firstPass(err) { - assert.strictEqual(err, null); - assert.strictEqual(linted().length, 2); - - require(eslintPath)._reset(); - require(eslintPath)._readAcrossFiles(true); - next = secondPass; - writeFileSync(leaf, "const leaf = 2;\n"); - } - }); - - it("should lint everything with a build that is not watched, even when told to wait", async () => { - writeFileSync(leaf, "const leaf = 1;\n"); - writeFileSync(entry, "require('./watch-leaf');\nconst entry = 1;\n"); - require(eslintPath)._reset(); - require(eslintPath)._readAcrossFiles(true); - - await pack("watch", { - eslintPath, - lintDependentsAfterBuild: true, - }).runAsync(); - - assert.strictEqual(linted().length, 2); - }); - it("should turn the tool's cache off where a rule reads other files", (t, done) => { writeFileSync(leaf, "const leaf = 1;\n"); writeFileSync(entry, "require('./watch-leaf');\nconst entry = 1;\n"); diff --git a/test/store.test.js b/test/store.test.js index 09b7f53a..e9059584 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -107,103 +107,3 @@ describe("store", () => { assert.deepStrictEqual(await reported(second), []); }); }); - -describe("cancel", () => { - /** - * @param {string[]} cancelled where each stopped run is written down - * @returns {EXPECTED_ANY} an adapter whose runs can be stopped - */ - function adapterCancelled(cancelled) { - const adapter = adapterFinding([]); - const counted = { - ...adapter, - runs: 0, - create: async () => { - const instance = await adapter.create(); - - return { - ...instance, - // Slow enough for a second compiler to join it. - lintFiles: async (/** @type {string[]} */ files) => { - counted.runs += 1; - - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); - - return instance.lintFiles(files); - }, - cancel: () => cancelled.push("cancelled"), - }; - }, - }; - - return counted; - } - - /** - * @param {EXPECTED_ANY} runner the runner whose report nobody waits for - * @returns {Promise} once the runner has been left to finish - */ - async function leave(runner) { - runner.detach(runner.report()); - } - - it("should stop a run of an older compilation nothing else waits for", async () => { - const cancelled = []; - const adapter = adapterCancelled(cancelled); - const compiler = { outputPath: "/out" }; - const first = runnerFor(adapter, compiler); - - first.lint(["a.css"]); - await leave(first); - await reported(runnerFor(adapter, compiler)); - - assert.deepStrictEqual(cancelled, ["cancelled"]); - }); - - it("should leave running a run another compiler joined", async () => { - const cancelled = []; - const adapter = adapterCancelled(cancelled); - const one = { outputPath: "/one" }; - const first = runnerFor(adapter, one); - const other = runnerFor(adapter, { outputPath: "/two" }); - - // Both lint the same files at once, so the second joins the first's run. - first.lint(["a.css"]); - other.lint(["a.css"]); - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - await leave(first); - await reported(runnerFor(adapter, one)); - await reported(other); - - assert.deepStrictEqual(cancelled, []); - }); - - it("should not let a compiler join a run that was stopped", async () => { - const cancelled = []; - const adapter = adapterCancelled(cancelled); - const one = { outputPath: "/one" }; - const first = runnerFor(adapter, one); - - first.lint(["a.css"]); - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - await leave(first); - - // The next compilation stops the run while it is still going. - const next = runnerFor(adapter, one); - const other = runnerFor(adapter, { outputPath: "/two" }); - - other.lint(["a.css"]); - await Promise.all([reported(next), reported(other)]); - - assert.deepStrictEqual(cancelled, ["cancelled"]); - // The other compiler ran the files itself rather than taking the - // stopped run's answer. - assert.strictEqual(adapter.runs, 2); - }); -}); diff --git a/test/typescript/cancel.test.js b/test/typescript/cancel.test.js deleted file mode 100644 index 72d89b7b..00000000 --- a/test/typescript/cancel.test.js +++ /dev/null @@ -1,155 +0,0 @@ -import assert from "node:assert/strict"; -import { - existsSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, it } from "node:test"; - -import { countThreads } from "../../src/threads.js"; - -import pack from "./utils/pack.js"; - -const typescriptPath = join(import.meta.dirname, "mock/typescript-stall"); - -const fixture = join(import.meta.dirname, "fixtures", "cancel"); -const checked = join(fixture, "checked.ts"); -const configFile = join(fixture, "tsconfig.json"); -const trigger = join(fixture, "trigger.js"); - -describe("cancel", () => { - let watch; - let outcome; - - afterEach(() => { - if (watch) watch.close(); - - rmSync(checked, { force: true }); - rmSync(configFile, { force: true }); - rmSync(trigger, { force: true }); - rmSync(outcome, { force: true }); - }); - - // The check only runs on a worker of its own where there are threads to - // spare, and only there can a run be stopped while it goes on. - it( - "should stop a run nobody waits for once the next build starts", - { skip: countThreads("auto") < 2 }, - (t, done) => { - outcome = join( - mkdtempSync(join(tmpdir(), "typescript-stall-")), - "outcome", - ); - // Read by the stalling TypeScript, on the worker the check runs on. - process.env.TYPESCRIPT_STALL_OUTCOME = outcome; - - writeFileSync( - configFile, - `${JSON.stringify({ - compilerOptions: { strict: true, skipLibCheck: true }, - include: ["*.ts"], - })}\n`, - ); - writeFileSync(trigger, "export const trigger = 1;\n"); - writeFileSync(checked, "export const fine = 1;\n"); - - /** @type {string[]} */ - const logged = []; - const quiet = () => {}; - const started = Date.now(); - let step = 0; - - const compiler = pack( - "cancel", - // Logged rather than reported, which is what leaves a run to finish - // after the build it was started for. - { threads: "auto", typescriptPath, reportAs: "log" }, - { - infrastructureLogging: { - level: "error", - console: { - error: (/** @type {string} */ message) => { - logged.push(message); - - if (!/TS2322/u.test(message)) return; - - assert.strictEqual(readFileSync(outcome, "utf8"), "cancelled"); - // What the stale run found is never printed. - assert.strictEqual(logged.length, 1); - assert.ok(Date.now() - started < 30000); - done(); - }, - warn: quiet, - info: quiet, - log: quiet, - debug: quiet, - trace: quiet, - group: quiet, - groupEnd: quiet, - groupCollapsed: quiet, - status: quiet, - clear: quiet, - profile: quiet, - profileEnd: quiet, - }, - }, - }, - ); - - watch = compiler.watch({}, (err, stats) => { - assert.strictEqual(err, null); - - const changed = [...(stats.compilation.compiler.modifiedFiles || [])]; - - if (step === 0) { - // A rebuild webpack starts for what was written before it watched. - if (changed.length > 0) return; - - step = 1; - // The run this build leaves behind stalls until it is cancelled. - writeFileSync(checked, "// stall\nexport const fine = 2;\n"); - - return; - } - - if (step === 1) { - // Webpack names the folder that holds it too. - const others = changed.filter((file) => file !== fixture); - - if (others.length !== 1 || others[0] !== checked) { - // A rebuild for what was written before the watch, late: the - // edit is made again so that a run is left with it. - writeFileSync( - checked, - `// stall\nexport const fine = ${Date.now()};\n`, - ); - - return; - } - - step = 2; - - // The worker reads the file when its run gets to it, so the next - // edit waits until that run is stuck on this one. - const stuck = setInterval(() => { - if (!existsSync(outcome)) return; - - clearInterval(stuck); - // A rebuild webpack starts late on its own, as it can on Windows, - // has stopped the run by now as well: either way it is stuck on - // this file or was stopped while it was. - assert.match( - readFileSync(outcome, "utf8"), - /^(stalling|cancelled)$/u, - ); - writeFileSync(checked, "export const wrong: string = 42;\n"); - }, 10); - } - }); - }, - ); -}); diff --git a/test/typescript/fixtures/cancel/index.js b/test/typescript/fixtures/cancel/index.js deleted file mode 100644 index aa624046..00000000 --- a/test/typescript/fixtures/cancel/index.js +++ /dev/null @@ -1 +0,0 @@ -import "./trigger.js"; diff --git a/test/typescript/mock/typescript-stall/index.js b/test/typescript/mock/typescript-stall/index.js deleted file mode 100644 index 692f89f1..00000000 --- a/test/typescript/mock/typescript-stall/index.js +++ /dev/null @@ -1,62 +0,0 @@ -// Real TypeScript, except that a program holding a file marked to stall keeps -// checking it until the run is cancelled, and says which way it ended. -const { writeFileSync } = require("node:fs"); - -const typescript = require("typescript"); - -const LIMIT = 30000; -const pause = new Int32Array(new SharedArrayBuffer(4)); - -/** - * @param {import("typescript").CancellationToken=} token what a run is cancelled through - */ -function stall(token) { - const started = Date.now(); - - writeFileSync(process.env.TYPESCRIPT_STALL_OUTCOME, "stalling"); - - while (Date.now() - started < LIMIT) { - if (token && token.isCancellationRequested()) { - writeFileSync(process.env.TYPESCRIPT_STALL_OUTCOME, "cancelled"); - token.throwIfCancellationRequested(); - } - - Atomics.wait(pause, 0, 0, 5); - } - - writeFileSync(process.env.TYPESCRIPT_STALL_OUTCOME, "timed out"); -} - -/** - * @param {(...args: unknown[]) => import("typescript").BuilderProgram} make the builder to wrap - * @returns {(...args: unknown[]) => import("typescript").BuilderProgram} it, stalling where a file asks it to - */ -const stalling = - (make) => - (...args) => { - const builder = make(...args); - const check = builder.getSemanticDiagnostics.bind(builder); - - builder.getSemanticDiagnostics = (file, token) => { - const stalls = builder - .getProgram() - .getSourceFiles() - .some((source) => source.text.includes("// stall")); - - if (stalls) stall(token); - - return check(file, token); - }; - - return builder; - }; - -module.exports = { - ...typescript, - createSemanticDiagnosticsBuilderProgram: stalling( - typescript.createSemanticDiagnosticsBuilderProgram, - ), - createEmitAndSemanticDiagnosticsBuilderProgram: stalling( - 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 index 6502a937..99e32c3b 100644 --- a/test/typescript/native.test.js +++ b/test/typescript/native.test.js @@ -50,18 +50,25 @@ describe("typescript 7", () => { const native = startNative(await loadNative(typescriptPath), options); /** @type {string[]} */ const order = []; - const snapshot = native.api.updateSnapshot.bind(native.api); - - native.api.updateSnapshot = async (/** @type {unknown} */ change) => { + /** + * @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 snapshot(change); + 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, {}), @@ -149,6 +156,23 @@ describe("typescript 7", () => { } }); + 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-")); diff --git a/types/check.d.ts b/types/check.d.ts index 289ea87b..9d18b0b2 100644 --- a/types/check.d.ts +++ b/types/check.d.ts @@ -32,11 +32,6 @@ export type Runner = { detach: (report: Promise) => void; }; export type ResultStore = Map; -export type SharedRun = { - key: string; - run: Promise; - joined: boolean; -}; export type EXPECTED_ANY = any; /** * What tells one check's run of a tool from another's, so that only two runs diff --git a/types/checks/index.d.ts b/types/checks/index.d.ts index 9398736c..513ad84b 100644 --- a/types/checks/index.d.ts +++ b/types/checks/index.d.ts @@ -86,10 +86,6 @@ export type CheckInstance = { * releases whatever the tool holds after a run */ cleanup: () => Promise; - /** - * stops a run of an older compilation that is still going, which nothing reports any more; what it answers with is dropped - */ - cancel?: (() => void) | undefined; }; /** * What a check shipped outside this package has to provide; everything the @@ -245,7 +241,6 @@ export type CheckAdapter = { * @property {((results: CheckResult[], keep: (message: Message) => boolean) => CheckResult[])=} filterResults keeps what a predicate accepts of every result, which is what `ignore` is applied through * @property {(formatter?: FormatterOption) => Promise} getFormatter loads a formatter, falling back to the tool's default one * @property {() => Promise} cleanup releases whatever the tool holds after a run - * @property {(() => void)=} cancel stops a run of an older compilation that is still going, which nothing reports any more; what it answers with is dropped */ /** * What a check shipped outside this package has to provide; everything the diff --git a/types/checks/stylelint.check.d.ts b/types/checks/stylelint.check.d.ts index a990625b..8f6d87b0 100644 --- a/types/checks/stylelint.check.d.ts +++ b/types/checks/stylelint.check.d.ts @@ -10,8 +10,8 @@ export function validate( rootData?: any; }, ): any; -export default r; -declare function r( +export default t; +declare function t( e: any, { instancePath: s, diff --git a/types/checks/typescript-native.d.ts b/types/checks/typescript-native.d.ts index 9bd1ced6..f7b40245 100644 --- a/types/checks/typescript-native.d.ts +++ b/types/checks/typescript-native.d.ts @@ -5,8 +5,6 @@ export type Options = import("../options.js").CheckOptions; */ export type NativeDiagnostic = { fileName?: string; - pos: number; - end: number; code: number; category: number; text: string; @@ -26,10 +24,11 @@ export type Described = { * A client of the native compiler and what it last saw of the project. */ export type Native = { + loaded: EXPECTED_ANY; api: EXPECTED_ANY; - error: number; snapshot: EXPECTED_ANY; files: Set; + turn: Promise; }; /** * Checks the program the config file describes with the native compiler, which @@ -49,10 +48,11 @@ export function checkNative( 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. + * 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 this is TypeScript 7 or later + * @returns {Promise} the native API, if the package ships one */ export function loadNative(specifier: string): Promise; /** diff --git a/types/checks/typescript-program.d.ts b/types/checks/typescript-program.d.ts index 6474e49d..396dddce 100644 --- a/types/checks/typescript-program.d.ts +++ b/types/checks/typescript-program.d.ts @@ -15,7 +15,6 @@ export type Found = { directories: string[]; writes: string[]; extensions: string[]; - cancelled?: boolean; }; export type Held = { signature: string; @@ -41,15 +40,9 @@ export type Held = { * @param {TypeScript} ts the loaded TypeScript * @param {Options} options options * @param {Held} held what the last compilation left behind - * @param {EXPECTED_ANY=} cancellation what tells a run nobody waits for any more to stop * @returns {Found} what it found, and what it read to find it */ -export function check( - ts: TypeScript, - options: Options, - held: Held, - cancellation?: EXPECTED_ANY | undefined, -): Found; +export function check(ts: TypeScript, options: Options, held: Held): Found; /** * What a diagnostic says, as data: a `Diagnostic` holds the source file it was * found in, so this is what can be read once it has left the check. diff --git a/types/checks/typescript-worker.d.ts b/types/checks/typescript-worker.d.ts index 490a01a4..c2bbbf9c 100644 --- a/types/checks/typescript-worker.d.ts +++ b/types/checks/typescript-worker.d.ts @@ -4,14 +4,9 @@ export type TypeScript = EXPECTED_ANY; /** * Runs the check and answers with what crosses back: a `Diagnostic` holds the * source file it was found in, so each one is described and formatted here. - * @param {string[]} files nothing: the program is the config file's - * @param {Int32Array} stopped set once nobody waits for this run, shared with the plugin * @returns {Promise} what it found, and what it read to find it */ -export function lintFiles( - files: string[], - stopped: Int32Array, -): Promise; +export function lintFiles(): Promise; /** * @param {Options} options the options the check was configured with */ diff --git a/types/options.d.ts b/types/options.d.ts index aea28177..00c7b3c2 100644 --- a/types/options.d.ts +++ b/types/options.d.ts @@ -48,10 +48,6 @@ export type SharedOptions = { * what a check finds that is not reported */ ignoreDiagnostics?: Ignore | undefined; - /** - * whether a watch rebuild lints the files that can turn on a change after the build rather than during it - */ - lintDependentsAfterBuild?: boolean | undefined; /** * whether what a check reports of one file can turn on another, read from the tool's configuration when left out */ diff --git a/types/threads.d.ts b/types/threads.d.ts index 6ce9ca58..3252c9da 100644 --- a/types/threads.d.ts +++ b/types/threads.d.ts @@ -10,7 +10,6 @@ export type Pool = { }; export type Solo = { lintFiles: () => Promise; - cancel: (answer: Promise) => void; end: () => Promise; }; export type EXPECTED_ANY = any; @@ -19,7 +18,7 @@ export type EXPECTED_ANY = any; /** @typedef {(files: string[]) => Promise} LintTask */ /** @typedef {JestWorker & { lintFiles: LintTask }} Worker */ /** @typedef {{ lintFiles: LintTask, end: () => Promise }} Pool */ -/** @typedef {{ lintFiles: () => Promise, cancel: (answer: Promise) => void, end: () => Promise }} Solo */ +/** @typedef {{ lintFiles: () => Promise, end: () => Promise }} Solo */ /** * How many threads the user asked for, as a count. A check is spread over one * fewer thread than the machine has, leaving webpack the one it builds on, and From 85d0b8c39d6b33578101d0939a507b9a081b825b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 11:30:44 +0000 Subject: [PATCH 6/6] fix: watch the config files a TypeScript 7 project extends, and let a run finish before closing The native check reported only the root config file to the watcher, so an edit to a config file it extends, outside the folder watched, went unnoticed unless webpack's own tsconfig resolution happened to watch it. It now reports every config file the program names (getConfigFileNames), and tells the compiler of a change to one as a change rather than a new file. Closing the native process now waits for a run still going. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01App18sYbe6Vf6HDCjCXbvf --- src/checks/typescript-native.js | 15 +++++--- test/typescript/native.test.js | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/checks/typescript-native.js b/src/checks/typescript-native.js index 0f980509..f996a07d 100644 --- a/src/checks/typescript-native.js +++ b/src/checks/typescript-native.js @@ -238,13 +238,17 @@ async function checkOnce(native, options, compiler) { }); } - native.files = new Set((await program.getSourceFileNames()).map(keyOf)); + // 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, - // The config file decides which files the program holds, so reading it - // again is what a change to it takes. - files: [configFile, ...parsedCommandLine.fileNames], + files: [...configs, ...parsedCommandLine.fileNames], // Where a file the program has never held can appear. directories: [dirname(configFile)], }; @@ -292,6 +296,9 @@ function startNative(loaded, options) { * @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(); diff --git a/test/typescript/native.test.js b/test/typescript/native.test.js index 99e32c3b..87fd8043 100644 --- a/test/typescript/native.test.js +++ b/test/typescript/native.test.js @@ -22,6 +22,11 @@ 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; @@ -32,6 +37,7 @@ describe("typescript 7", () => { 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 () => { @@ -224,4 +230,62 @@ describe("typescript 7", () => { 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(); + }); + }, + ); });