From 78836743478278fd42ec5c1fbbf6edfa77621543 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 13:09:21 +0000 Subject: [PATCH 1/2] perf: reach a changed file's importers only when what it shows them changes A watch rebuild under typed linting (and any check reading across files) linted again every file importing a changed one, however indirectly, whatever the edit. An edit to a function's body or a comment changes nothing an importer can see. What a changed file shows its importers - the declarations `ts.transpileDeclaration` emits for it, and what it imports - is hashed and kept. When the next change to it shows the same, only the file itself is linted again. A quiet file reached through something it imports still passes that on: it can show a changed type through an `export *` or a type it names. Unknowable without a type checker (an inferred return type), on TypeScript below 5.5, or for a file not parsed as TypeScript, it is taken to have changed, as before. The first edit of a file in a session is what the next is told from. 301 modules in a chain, typed ESLint, one halfway along edited five times: rebuilds 2-5 1.0-1.4 s -> 158-205 ms (eslint-webpack-plugin, which goes stale: ~210 ms). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01App18sYbe6Vf6HDCjCXbvf --- .changeset/quiet-edits-reach-no-importers.md | 5 + README.md | 9 ++ src/import-graph.js | 108 ++++++++++++++++--- test/import-graph.test.js | 78 ++++++++++++++ types/import-graph.d.ts | 4 + 5 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 .changeset/quiet-edits-reach-no-importers.md diff --git a/.changeset/quiet-edits-reach-no-importers.md b/.changeset/quiet-edits-reach-no-importers.md new file mode 100644 index 0000000..d7e67ea --- /dev/null +++ b/.changeset/quiet-edits-reach-no-importers.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": minor +--- + +Lint again the importers of a file changed while webpack watches only when what it shows them changes — the declarations it emits and what it imports — rather than on every edit, such as one to the body of a function or a comment. diff --git a/README.md b/README.md index a59d455..997458a 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,15 @@ nothing — lints every file again, as does a project without TypeScript. 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. +A file changed while webpack watches reaches its importers only when what it +shows them changes: the declarations it emits and what it imports. Edit the +body of a function or a comment and only that file is linted again — told +apart by `ts.transpileDeclaration` (TypeScript 5.5 and later), which needs no +type checker and so answers only for a file whose exports say their own types, +and only from the second edit of a file on, the first being what it is told +from. Over three hundred modules in a chain, editing one halfway along took a +typed-linting rebuild from 1.1–1.4 s to 0.16–0.2 s. + Left out, each check reads it from its tool's configuration: | Check | Reads across files when | diff --git a/src/import-graph.js b/src/import-graph.js index 8933662..b277f66 100644 --- a/src/import-graph.js +++ b/src/import-graph.js @@ -39,6 +39,7 @@ import { toPosixPath } from "./utils.js"; * @property {Map} options the compiler options of each config file * @property {Map} reads every file read for a signature, as it was read * @property {Map} configHashes each config file and what it extends, hashed, as the signatures were last read under + * @property {Map} faces what each file that changed showed the files importing it, as the last change left it, hashed */ // What the options of a directory no config file covers are taken as: what a @@ -57,6 +58,7 @@ function createGraph(ts) { options: new Map(), reads: new Map(), configHashes: new Map(), + faces: new Map(), }; } @@ -152,6 +154,55 @@ function readImports(graph, file) { return text === undefined ? undefined : importsOf(graph, file, text); } +/** + * @param {string} text what to hash + * @returns {string} its hash + */ +function hashOf(text) { + return createHash("sha1").update(text).digest("hex"); +} + +/** + * What a file shows the files importing it: the declarations it emits, and + * what it imports, which a rule following imports across files reads. Read + * with `transpileDeclaration`, which needs no type checker and so answers only + * for a file whose exports say their own types; for any other file, for a + * TypeScript older than 5.5, and for what does not parse as TypeScript at all, + * there is no answer. + * @param {Graph} graph the graph + * @param {string} file the file, as a posix path + * @param {string} text what it holds + * @param {Imports} imports what it imports + * @returns {string | undefined} it, hashed, or nothing where it cannot be said + */ +function faceOf(graph, file, text, imports) { + const { ts } = graph; + + if (typeof ts.transpileDeclaration !== "function") return undefined; + + try { + const emitted = ts.transpileDeclaration(text, { + fileName: file, + reportDiagnostics: true, + compilerOptions: { ...optionsFor(graph, file), declaration: true }, + }); + + if (emitted.diagnostics && emitted.diagnostics.length > 0) { + return undefined; + } + + return hashOf( + [ + emitted.outputText, + ...imports.resolved.toSorted(), + ...imports.missing.toSorted(), + ].join("\0"), + ); + } catch { + return undefined; + } +} + /** * 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 @@ -171,10 +222,17 @@ function affectedFiles(graph, files, changed) { graph.files.clear(); graph.configs.clear(); graph.options.clear(); + graph.faces.clear(); return undefined; } + // The files whose change shows the files importing them nothing new: an + // edit to the body of a function, a comment. Linted again themselves, but + // what imports them is not, for what this edit did. + /** @type {Set} */ + const quiet = new Set(); + for (const file of touched) { const before = graph.files.get(file); @@ -183,6 +241,27 @@ function affectedFiles(graph, files, changed) { if (before && (before.global || before.unresolved)) return undefined; graph.files.delete(file); + + const text = graph.ts.sys.readFile(file); + + if (text === undefined) { + graph.faces.delete(file); + continue; + } + + const imports = importsOf(graph, file, text); + const was = graph.faces.get(file); + // Only a file that changed is read for it: the first edit of a file is + // taken to reach everything importing it, and the next ones are told from + // what it showed after that one. + const face = faceOf(graph, file, text, imports); + + graph.files.set(file, imports); + + if (face === undefined) graph.faces.delete(file); + else graph.faces.set(file, face); + + if (was !== undefined && was === face) quiet.add(file); } /** @type {Map} */ @@ -219,13 +298,26 @@ function affectedFiles(graph, files, changed) { /** @type {Set} */ const reached = new Set(); - const queue = [...touched]; + /** @type {Set} */ + const spread = new Set(); + // Each file with whether what it shows can have changed. A quiet file + // reached through something it imports is not quiet any more: it can show + // a changed type through an `export *` or a type it names. + /** @type {[string, boolean][]} */ + const queue = touched.map((file) => [file, !quiet.has(file)]); - for (let file = queue.pop(); file !== undefined; file = queue.pop()) { - if (reached.has(file)) continue; + for (let next = queue.pop(); next !== undefined; next = queue.pop()) { + const [file, loud] = next; reached.add(file); - queue.push(...(importers.get(file) || [])); + + if (!loud || spread.has(file)) continue; + + spread.add(file); + + for (const importer of importers.get(file) || []) { + queue.push([importer, true]); + } } return new Set([...reached].filter((file) => covered.has(file))); @@ -281,14 +373,6 @@ function getGraph(compiler, context, owner) { return graph; } -/** - * @param {string} text what to hash - * @returns {string} its hash - */ -function hashOf(text) { - return createHash("sha1").update(text).digest("hex"); -} - /** * @param {string} file a resolved file * @returns {boolean} whether a package rather than the project holds it diff --git a/test/import-graph.test.js b/test/import-graph.test.js index cec262a..a56fd7a 100644 --- a/test/import-graph.test.js +++ b/test/import-graph.test.js @@ -79,6 +79,84 @@ describe("import graph", () => { ]); }); + describe("what a changed file shows the files importing it", () => { + // Asked twice: what a file showed is known from its first change on. + const twice = (name, first, second) => { + write(name, first); + affected(all, [name]); + write(name, second); + + return affected(all, [name]); + }; + + it("should leave its importers be once only its body or comments change", () => { + assert.deepStrictEqual( + twice( + "leaf.ts", + "export const leaf: number = 2;\n", + "// a note\nexport const leaf: number = 3;\n", + ), + ["leaf.ts"], + ); + }); + + it("should reach its importers once a type it exports changes", () => { + assert.deepStrictEqual( + twice( + "leaf.ts", + "export const leaf: number = 2;\n", + 'export const leaf: string = "2";\n', + ), + ["leaf.ts", "middle.ts", "top.ts"], + ); + }); + + it("should reach its importers once what it imports changes", () => { + assert.deepStrictEqual( + twice( + "leaf.ts", + "export const leaf: number = 2;\n", + 'import "./alone";\nexport const leaf: number = 2;\n', + ), + ["leaf.ts", "middle.ts", "top.ts"], + ); + }); + + it("should reach its importers where its exports do not say their own types", () => { + // What it returns takes a type checker to say, which the declaration + // read without one writes as `any` for both. + assert.deepStrictEqual( + twice( + "leaf.ts", + "export function make() { return [1].map((x) => x); }\n", + 'export function make() { return ["1"].map((x) => x); }\n', + ), + ["leaf.ts", "middle.ts", "top.ts"], + ); + }); + + it("should reach the importers of a quiet file that a loud change reaches", () => { + write( + "middle.ts", + 'import { leaf } from "./leaf";\nexport const m: number = leaf;\n', + ); + affected(all, ["middle.ts"]); + write( + "middle.ts", + 'import { leaf } from "./leaf";\nexport const m: number = leaf + 0;\n', + ); + write("leaf.ts", 'export const leaf: string = "1";\n'); + + // The middle file says the same, but through it the leaf's new type + // reaches the top. + assert.deepStrictEqual(affected(all, ["middle.ts", "leaf.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/types/import-graph.d.ts b/types/import-graph.d.ts index efb5f66..e9bf3ed 100644 --- a/types/import-graph.d.ts +++ b/types/import-graph.d.ts @@ -71,6 +71,10 @@ export type Graph = { * each config file and what it extends, hashed, as the signatures were last read under */ configHashes: Map; + /** + * what each file that changed showed the files importing it, as the last change left it, hashed + */ + faces: Map; }; /** * Which files a change can have changed what typed linting says of: the ones From cfc3f05b3c68c6f2053265a5c4341789fe43681a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 13:25:38 +0000 Subject: [PATCH 2/2] fix: read what a project's own emit drops when telling a file's face `removeComments` drops a `@deprecated` that `no-deprecated` reads in an importer, and `stripInternal` drops an `@internal` export, so a second edit adding either looked quiet. Both are turned off for the fingerprint. A declaration file, which `transpileDeclaration` refuses to emit, is skipped rather than caught, and the paths a TypeScript without it and a file that has gone take are now tested. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01App18sYbe6Vf6HDCjCXbvf --- src/import-graph.js | 49 ++++++++++++++++++++-------------- test/import-graph.test.js | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 20 deletions(-) diff --git a/src/import-graph.js b/src/import-graph.js index b277f66..7bb2918 100644 --- a/src/import-graph.js +++ b/src/import-graph.js @@ -178,29 +178,38 @@ function hashOf(text) { function faceOf(graph, file, text, imports) { const { ts } = graph; - if (typeof ts.transpileDeclaration !== "function") return undefined; - - try { - const emitted = ts.transpileDeclaration(text, { - fileName: file, - reportDiagnostics: true, - compilerOptions: { ...optionsFor(graph, file), declaration: true }, - }); - - if (emitted.diagnostics && emitted.diagnostics.length > 0) { - return undefined; - } + // A declaration file has nothing to emit, and TypeScript refuses to. + if ( + typeof ts.transpileDeclaration !== "function" || + /\.d\.[^/]*ts$/u.test(file) + ) { + return undefined; + } - return hashOf( - [ - emitted.outputText, - ...imports.resolved.toSorted(), - ...imports.missing.toSorted(), - ].join("\0"), - ); - } catch { + const emitted = ts.transpileDeclaration(text, { + fileName: file, + reportDiagnostics: true, + compilerOptions: { + ...optionsFor(graph, file), + declaration: true, + // What an importer's rules read and a project's own emit may drop: a + // `@deprecated` in a comment, an `@internal` export. + removeComments: false, + stripInternal: false, + }, + }); + + if (emitted.diagnostics && emitted.diagnostics.length > 0) { return undefined; } + + return hashOf( + [ + emitted.outputText, + ...imports.resolved.toSorted(), + ...imports.missing.toSorted(), + ].join("\0"), + ); } /** diff --git a/test/import-graph.test.js b/test/import-graph.test.js index a56fd7a..3667034 100644 --- a/test/import-graph.test.js +++ b/test/import-graph.test.js @@ -135,6 +135,61 @@ describe("import graph", () => { ); }); + it("should read what a project's own emit drops, which an importer's rules still see", () => { + write( + "tsconfig.json", + JSON.stringify({ + compilerOptions: { + strict: true, + moduleResolution: "bundler", + removeComments: true, + stripInternal: true, + }, + }), + ); + + assert.deepStrictEqual( + twice( + "leaf.ts", + "export const leaf: number = 2;\n", + "/** @deprecated */\nexport const leaf: number = 2;\n", + ), + ["leaf.ts", "middle.ts", "top.ts"], + ); + assert.deepStrictEqual( + twice( + "leaf.ts", + "export const leaf: number = 2;\n", + "export const leaf: number = 2;\n/** @internal */\nexport const hidden: number = 1;\n", + ), + ["leaf.ts", "middle.ts", "top.ts"], + ); + }); + + it("should reach its importers with a TypeScript that cannot say what a file shows", () => { + graph = createGraph({ ...ts, transpileDeclaration: undefined }); + + assert.deepStrictEqual( + twice( + "leaf.ts", + "export const leaf: number = 2;\n", + "// a note\nexport const leaf: number = 2;\n", + ), + ["leaf.ts", "middle.ts", "top.ts"], + ); + }); + + it("should reach the importers of a file that has gone", () => { + affected(all, ["leaf.ts"]); + rmSync(at("leaf.ts")); + + assert.deepStrictEqual(affected(all, ["leaf.ts"]), [ + "leaf.ts", + "middle.ts", + "top.ts", + ]); + }); + it("should reach the importers of a quiet file that a loud change reaches", () => { write( "middle.ts",