From 96d70732cb1355f3d07532f74f2b4d94db496b6b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:44:11 +0000 Subject: [PATCH 1/3] perf: sign typed-lint results in one pass over the import graph Each file's signature walked everything it imports, however indirectly, again for every file, so a project whose files import a barrel that imports them back paid for the whole project once per file. Files are now signed by what the files they import are signed by, and the files of an import cycle together, in one pass over the graph. Over 6,000 such modules, signatures took 27 s a build and now take 0.13-0.57 s. Refs #374. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013FhutQipYi25fJ4rs9WE1c --- .changeset/linear-signatures.md | 5 ++ src/import-graph.js | 124 +++++++++++++++++++++++++++++--- test/import-graph.test.js | 46 ++++++++++++ 3 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 .changeset/linear-signatures.md diff --git a/.changeset/linear-signatures.md b/.changeset/linear-signatures.md new file mode 100644 index 00000000..a1c4daa6 --- /dev/null +++ b/.changeset/linear-signatures.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": patch +--- + +Sign the files kept under typed linting in one pass over the import graph rather than walking everything each file imports again for every file, which grew with the square of the project where files import a barrel that imports them back. Over 6,000 such modules, telling which kept results still hold took 27 s a build and now takes 0.13–0.57 s. diff --git a/src/import-graph.js b/src/import-graph.js index 0142f1d3..58878b42 100644 --- a/src/import-graph.js +++ b/src/import-graph.js @@ -758,31 +758,133 @@ function signatures(graph, files) { return described; }; + // Every file reaching another is signed by what that one is signed by, so a + // change anywhere below a file changes it, and each file is read once rather + // than once per file importing it. The files of an import cycle reach the + // same files, and are signed together, as strongly connected components. /** @type {Map} */ - const found = new Map(); + const signed = new Map(); + /** @type {Map} */ + const order = new Map(); + /** @type {Map} */ + const lowest = new Map(); + /** @type {string[]} */ + const open = []; + /** @type {Set} */ + const onOpen = new Set(); - for (const file of wanted) { - if (!graph.reads.has(file)) continue; + /** + * @param {string} file the file, as a posix path + */ + const enter = (file) => { + order.set(file, order.size); + lowest.set(file, order.size - 1); + open.push(file); + onOpen.add(file); + }; + + /** + * Signs the cycle `file` opened, now that everything it imports is signed. + * @param {string} file the first file of the cycle reached + */ + const close = (file) => { + /** @type {Set} */ + const members = new Set(); + + for (let member = open.pop(); ; member = open.pop()) { + const known = /** @type {string} */ (member); + + onOpen.delete(known); + members.add(known); + + if (known === file) break; + } /** @type {string[]} */ const parts = []; - const seen = new Set([file]); - const queue = [file]; + /** @type {Set} */ + const below = new Set(); - for (let next = queue.pop(); next !== undefined; next = queue.pop()) { - const described = describe(next); + for (const member of members) { + const described = describe(member); parts.push(...described.parts); for (const target of described.imports) { - if (!seen.has(target)) { - seen.add(target); - queue.push(target); + if (!members.has(target)) { + below.add(/** @type {string} */ (signed.get(target))); + } + } + } + + const signature = hashOf( + `${parts.toSorted().join("\n")}\n\n${[...below].toSorted().join("\n")}`, + ); + + for (const member of members) signed.set(member, signature); + }; + + for (const root of wanted) { + if (!graph.reads.has(root) || order.has(root)) continue; + + enter(root); + + /** @type {[string, number][]} */ + const stack = [[root, 0]]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const [file, next] = frame; + const { imports } = describe(file); + + if (next < imports.length) { + const target = imports[next]; + + frame[1] = next + 1; + + if (!order.has(target)) { + enter(target); + stack.push([target, 0]); + } else if (onOpen.has(target)) { + lowest.set( + file, + Math.min( + /** @type {number} */ (lowest.get(file)), + /** @type {number} */ (order.get(target)), + ), + ); } + + continue; } + + stack.pop(); + + if (stack.length > 0) { + const [parent] = stack[stack.length - 1]; + + lowest.set( + parent, + Math.min( + /** @type {number} */ (lowest.get(parent)), + /** @type {number} */ (lowest.get(file)), + ), + ); + } + + if (lowest.get(file) === order.get(file)) close(file); } + } + + /** @type {Map} */ + const found = new Map(); - found.set(file, hashOf(`${project}\n${parts.toSorted().join("\n")}`)); + for (const file of wanted) { + const signature = signed.get(file); + + if (signature !== undefined) { + found.set(file, hashOf(`${project}\n${signature}`)); + } } return found; diff --git a/test/import-graph.test.js b/test/import-graph.test.js index bd1a2d1d..720f5276 100644 --- a/test/import-graph.test.js +++ b/test/import-graph.test.js @@ -669,6 +669,52 @@ describe("signatures", () => { assert.ok(changed(moved, sign()).includes("leaf.ts")); }); + it("should change for every file of an import cycle, and what imports it, when one of them changes", () => { + write( + "leaf.ts", + 'import { m } from "./middle";\nexport const leaf = 1;\nexport const n = m;\n', + ); + + const before = sign(); + + write( + "leaf.ts", + 'import { m } from "./middle";\nexport const leaf = 2;\nexport const n = m;\n', + ); + + assert.deepStrictEqual(changed(before, sign()), [ + "leaf.ts", + "middle.ts", + "top.ts", + ]); + }); + + it("should change for an import cycle when a file it imports changes", () => { + write( + "leaf.ts", + 'import { m } from "./middle";\nimport { alone } from "./alone";\nexport const leaf = alone;\nexport const n = m;\n', + ); + + const before = sign(); + + write("alone.ts", "export const alone = 2;\n"); + + assert.deepStrictEqual(changed(before, sign()), names); + }); + + it("should not change for an import cycle when a file importing it changes", () => { + write( + "leaf.ts", + 'import { m } from "./middle";\nexport const leaf = 1;\nexport const n = m;\n', + ); + + const before = sign(); + + write("top.ts", 'import { m } from "./middle";\nexport const t = 2;\n'); + + assert.deepStrictEqual(changed(before, sign()), ["top.ts"]); + }); + it("should change once an import that resolved to nothing does", () => { write( "alone.ts", From 1e52a43c589c9d2fe94db52958aeb115b737a589 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:00:54 +0000 Subject: [PATCH 2/3] test: judge the first-edit watch test by the rebuild its edit caused On Windows, webpack's watcher can rebuild right as the watch starts, reading the fixtures just written as changed. That rebuild lints every file, and the test took it for the one its edit caused a second later: it failed in about 100 ms with 2 files linted where 1 was expected, on main as well as here. Rebuilds before the edit is written are skipped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013FhutQipYi25fJ4rs9WE1c --- test/incremental.test.js | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/test/incremental.test.js b/test/incremental.test.js index 3cf8188d..24a35f4c 100644 --- a/test/incremental.test.js +++ b/test/incremental.test.js @@ -168,7 +168,7 @@ describe("incremental", () => { * @param {object} options plugin options * @param {(files: string[]) => void} check what to say of what the rebuild linted * @param {() => void} done done - * @param {() => void} edit what changes once the first build is over + * @param {() => void | Promise} edit what changes once the first build is over */ const rebuild = ( options, @@ -177,6 +177,7 @@ describe("incremental", () => { edit = () => writeFileSync(leaf, "const leaf = 2;\n"), ) => { let first = true; + let edited = false; watch = pack("watch", { eslintPath, ...options }).watch({}, (err) => { assert.strictEqual(err, null); @@ -185,7 +186,18 @@ describe("incremental", () => { first = false; require(eslintPath)._reset(); require(eslintPath)._readAcrossFiles(true); - edit(); + Promise.resolve(edit()).then(() => { + edited = true; + }); + + return; + } + + // A rebuild before the edit is not the one under test: on Windows, + // webpack's watcher reads the fixtures just written as changed. + if (!edited) { + require(eslintPath)._reset(); + require(eslintPath)._readAcrossFiles(true); return; } @@ -240,12 +252,15 @@ describe("incremental", () => { // the files held as the watch started has been read, which a change // made before then would be read as. () => - setTimeout(() => { - writeFileSync( - faceLeaf, - "export function leaf(): number {\n return 2;\n}\n", - ); - }, 1000), + new Promise((resolve) => { + setTimeout(() => { + writeFileSync( + faceLeaf, + "export function leaf(): number {\n return 2;\n}\n", + ); + resolve(); + }, 1000); + }), ); }); From d53d48dd105eaebe3b0d3e34e1bc6e583feaf453 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:05:30 +0000 Subject: [PATCH 3/3] test: skip a rebuild that started before the edit, not one that ended before it A rebuild already running when the edit is written can still reach the watch callback once the edit is done, if the write lands while its done hooks run. Its start time, not when its callback arrives, now tells it from the rebuild the edit caused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013FhutQipYi25fJ4rs9WE1c --- test/incremental.test.js | 61 +++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/test/incremental.test.js b/test/incremental.test.js index 24a35f4c..d4845652 100644 --- a/test/incremental.test.js +++ b/test/incremental.test.js @@ -177,35 +177,38 @@ describe("incremental", () => { edit = () => writeFileSync(leaf, "const leaf = 2;\n"), ) => { let first = true; - let edited = false; - - watch = pack("watch", { eslintPath, ...options }).watch({}, (err) => { - assert.strictEqual(err, null); - - if (first) { - first = false; - require(eslintPath)._reset(); - require(eslintPath)._readAcrossFiles(true); - Promise.resolve(edit()).then(() => { - edited = true; - }); - - return; - } - - // A rebuild before the edit is not the one under test: on Windows, - // webpack's watcher reads the fixtures just written as changed. - if (!edited) { - require(eslintPath)._reset(); - require(eslintPath)._readAcrossFiles(true); - - return; - } - - rmSync(other, { force: true }); - check(linted()); - done(); - }); + let editedAt = Infinity; + + watch = pack("watch", { eslintPath, ...options }).watch( + {}, + (err, stats) => { + assert.strictEqual(err, null); + + if (first) { + first = false; + require(eslintPath)._reset(); + require(eslintPath)._readAcrossFiles(true); + Promise.resolve(edit()).then(() => { + editedAt = Date.now(); + }); + + return; + } + + // A rebuild started before the edit is not the one under test: on + // Windows, webpack's watcher reads the fixtures just written as changed. + if (stats.startTime < editedAt) { + require(eslintPath)._reset(); + require(eslintPath)._readAcrossFiles(true); + + return; + } + + rmSync(other, { force: true }); + check(linted()); + done(); + }, + ); }; it("should leave the importers of a file be on its first change when what they see of it holds", (t, done) => {