Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sign-once-per-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"diagnostics-webpack-plugin": patch
---

Read the files kept under typed linting once per build rather than once per batch of files webpack hands over while it builds: every batch read the `tsconfig.json` and checked every file of the program again. Over 3,000 modules checked by both typed ESLint and the TypeScript check with `threads: false`, a build answered from what it kept took 13.3–13.7 s and now takes 11.9–12.4 s, and signing them took 1.9 s and now takes 0.3 s.
19 changes: 12 additions & 7 deletions src/checks/eslint.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createRequire } from "node:module";
import { isAbsolute, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

import { getGraph, hashOf, signatures } from "../import-graph.js";
import { createSigner, getGraph, hashOf } from "../import-graph.js";
import { openStore, saveStore } from "../result-store.js";
import { countThreads, createPool } from "../threads.js";
import { omitPluginOptions, toPosixPath } from "../utils.js";
Expand Down Expand Up @@ -387,8 +387,9 @@ async function create({ key, id, options, compilation }) {
let across;
/**
* Under typed linting, where ESLint's own cache cannot answer: the import
* graph each file's results are kept against, and where they are kept.
* @type {{ graph: import("../import-graph.js").Graph, store: import("../result-store.js").Store } | undefined}
* graph each file's results are kept against, where they are kept, and what
* signs the files of this build's batches.
* @type {{ graph: import("../import-graph.js").Graph, store: import("../result-store.js").Store, sign: (files: string[]) => Map<string, string> } | undefined}
*/
let remembered;

Expand Down Expand Up @@ -424,7 +425,11 @@ async function create({ key, id, options, compilation }) {
: undefined;

if (graph) {
remembered = { graph, store: openStore(resultsPathFor(eslintOptions)) };
remembered = {
graph,
store: openStore(resultsPathFor(eslintOptions)),
sign: createSigner(graph),
};
eslintOptions.cache = false;
changed = true;
} else if (cacheIsOurs) {
Expand Down Expand Up @@ -502,12 +507,12 @@ async function create({ key, id, options, compilation }) {
/**
* Answers for what was linted before under the same everything from what
* was found then, and lints the rest.
* @param {NonNullable<typeof remembered>} kept the graph and the store
* @param {NonNullable<typeof remembered>} kept the graph, the store and the signer
* @param {string[]} files the files to lint
* @returns {Promise<{ found: LintResult[], linted: LintResult[] }>} every result, and the ones linted now
*/
const lintRemembered = async ({ graph, store }, files) => {
const signed = signatures(graph, files);
const lintRemembered = async ({ store, sign }, files) => {
const signed = sign(files);
/** @type {Map<string, string>} */
const keys = new Map();
/** @type {LintResult[]} */
Expand Down
245 changes: 159 additions & 86 deletions src/import-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -624,11 +624,11 @@ function readConfig(graph, config) {
* files its imports resolve through, and the declaration files and global
* declarations of the programs they describe, which a file sees without
* importing them.
* @param {Graph} graph the graph
* @param {Map<string, { hash: string, sources: string[] }>} configs the config files as they are now
* @param {(file: string) => Read | undefined} read reads a file as it is now
* @returns {string} what they hold, hashed
*/
function projectSignature(graph, configs) {
function projectSignature(configs, read) {
/** @type {string[]} */
const parts = [];

Expand All @@ -640,7 +640,7 @@ function projectSignature(graph, configs) {

if (isPackage(file)) continue;

const known = readNow(graph, file);
const known = read(file);

if (!known) continue;

Expand All @@ -662,54 +662,56 @@ function projectSignature(graph, configs) {
* imports however indirectly, and what the whole project sees — so that a
* result kept for a file is good for as long as this is the same, and no
* longer. A package is read for when it was installed rather than for what it
* holds.
* holds. One signer answers for one build, which hands it its files in batches:
* what it read for one batch is not read again for the next.
* @param {Graph} graph the graph
* @param {string[]} files the files to answer for
* @returns {Map<string, string>} the signature of each of them that can be read, by its posix path
* @returns {(files: string[]) => Map<string, string>} the signature of each of the files given that can be read, by its posix path
*/
function signatures(graph, files) {
function createSigner(graph) {
graph.resolutions.clear();

const wanted = files.map((file) => toPosixPath(resolve(file)));

// Read first, so that the config files they resolve through are known.
for (const file of wanted) readNow(graph, file);

/**
* The files read in this build, whether or not they were there.
* @type {Set<string>}
*/
const checked = new Set();
/** @type {Map<string, { hash: string, sources: string[] }>} */
let configs = new Map();
const configsRead = new Map();
/** @type {{ configs: Map<string, { hash: string, sources: string[] }>, signature: string } | undefined} */
let project;

for (const config of graph.options.keys()) {
configs.set(config, readConfig(graph, config));
}
/**
* @param {string} file the file, as a posix path
* @returns {Read | undefined} it as it is in this build
*/
const read = (file) => {
if (checked.has(file)) return graph.reads.get(file);

// A config file that changed decides how every import resolves, so what was
// read under it is read again, under it as it is now.
if (
[...configs].some(
([config, { hash }]) => graph.configHashes.get(config) !== hash,
) &&
graph.configHashes.size > 0
) {
graph.reads.clear();
graph.options.clear();
graph.configs.clear();
checked.add(file);

for (const file of wanted) readNow(graph, file);
return readNow(graph, file);
};

configs = new Map();
/**
* @returns {Map<string, { hash: string, sources: string[] }>} every config file read so far, as it is in this build
*/
const readConfigs = () => {
/** @type {Map<string, { hash: string, sources: string[] }>} */
const configs = new Map();

for (const config of graph.options.keys()) {
configs.set(config, readConfig(graph, config));
}
}
let now = configsRead.get(config);

graph.configHashes.clear();
if (!now) {
now = readConfig(graph, config);
configsRead.set(config, now);
}

for (const [config, { hash }] of configs) {
graph.configHashes.set(config, hash);
}
configs.set(config, now);
}

const project = projectSignature(graph, configs);
return configs;
};
/**
* What each file says of itself, and what it imports, read once however
* many of the closures reach it.
Expand Down Expand Up @@ -739,17 +741,17 @@ function signatures(graph, files) {
imports: [],
};
} else {
const read = readNow(graph, file);
const now = read(file);

described = read
described = now
? {
parts: [
`${file} ${read.hash}`,
...read.imports.missing.map(
`${file} ${now.hash}`,
...now.imports.missing.map(
(specifier) => `${file} misses ${specifier}`,
),
],
imports: read.imports.resolved,
imports: now.imports.resolved,
}
: { parts: [`${file} gone`], imports: [] };
}
Expand Down Expand Up @@ -824,70 +826,141 @@ function signatures(graph, files) {
for (const member of members) signed.set(member, signature);
};

for (const root of wanted) {
if (!graph.reads.has(root) || order.has(root)) continue;
return (files) => {
const wanted = files.map((file) => toPosixPath(resolve(file)));

// Read first, so that the config files they resolve through are known.
for (const file of wanted) read(file);

let configs = readConfigs();

// A config file that changed decides how every import resolves, so what was
// read under it is read again, under it as it is now. One first reached in
// this batch has nothing read under it yet.
if (
[...configs].some(
([config, { hash }]) =>
graph.configHashes.has(config) &&
graph.configHashes.get(config) !== hash,
)
) {
Comment thread
alexander-akait marked this conversation as resolved.
Resolved
graph.reads.clear();
graph.options.clear();
graph.configs.clear();
checked.clear();
configsRead.clear();
known.clear();
signed.clear();
order.clear();
lowest.clear();
project = undefined;

for (const file of wanted) read(file);

configs = readConfigs();
}

graph.configHashes.clear();

for (const [config, { hash }] of configs) {
graph.configHashes.set(config, hash);
}

const kept = project;
const current =
!kept ||
kept.configs.size !== configs.size ||
[...configs].some(([config, now]) => kept.configs.get(config) !== now)
? { configs, signature: projectSignature(configs, read) }
: kept;

project = current;

const projectSigned = current.signature;

for (const root of wanted) {
if (!graph.reads.has(root) || order.has(root)) continue;

enter(root);

/** @type {[string, number][]} */
const stack = [[root, 0]];

enter(root);
while (stack.length > 0) {
const frame = stack[stack.length - 1];
const [file, next] = frame;
const { imports } = describe(file);

/** @type {[string, number][]} */
const stack = [[root, 0]];
if (next < imports.length) {
const target = imports[next];

while (stack.length > 0) {
const frame = stack[stack.length - 1];
const [file, next] = frame;
const { imports } = describe(file);
frame[1] = next + 1;

if (next < imports.length) {
const target = imports[next];
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;
}

frame[1] = next + 1;
stack.pop();

if (stack.length > 0) {
const [parent] = stack[stack.length - 1];

if (!order.has(target)) {
enter(target);
stack.push([target, 0]);
} else if (onOpen.has(target)) {
lowest.set(
file,
parent,
Math.min(
/** @type {number} */ (lowest.get(parent)),
/** @type {number} */ (lowest.get(file)),
/** @type {number} */ (order.get(target)),
),
);
}

continue;
if (lowest.get(file) === order.get(file)) close(file);
}
}

stack.pop();
/** @type {Map<string, string>} */
const found = new Map();

if (stack.length > 0) {
const [parent] = stack[stack.length - 1];
for (const file of wanted) {
const signature = signed.get(file);

lowest.set(
parent,
Math.min(
/** @type {number} */ (lowest.get(parent)),
/** @type {number} */ (lowest.get(file)),
),
);
if (signature !== undefined) {
found.set(file, hashOf(`${projectSigned}\n${signature}`));
}

if (lowest.get(file) === order.get(file)) close(file);
}
}

/** @type {Map<string, string>} */
const found = new Map();

for (const file of wanted) {
const signature = signed.get(file);

if (signature !== undefined) {
found.set(file, hashOf(`${project}\n${signature}`));
}
}
return found;
};
}

return found;
/**
* The signatures of files signed on their own, as one batch of a build.
* @param {Graph} graph the graph
* @param {string[]} files the files to answer for
* @returns {Map<string, string>} the signature of each of them that can be read, by its posix path
*/
function signatures(graph, files) {
return createSigner(graph)(files);
}

export { affectedFiles, createGraph, getGraph, hashOf, keepTexts, signatures };
export {
affectedFiles,
createGraph,
createSigner,
getGraph,
hashOf,
keepTexts,
signatures,
};
Loading
Loading