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/quiet-edits-reach-no-importers.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
117 changes: 105 additions & 12 deletions src/import-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { toPosixPath } from "./utils.js";
* @property {Map<string, EXPECTED_ANY>} options the compiler options of each config file
* @property {Map<string, Read>} reads every file read for a signature, as it was read
* @property {Map<string, string>} configHashes each config file and what it extends, hashed, as the signatures were last read under
* @property {Map<string, string>} 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
Expand All @@ -57,6 +58,7 @@ function createGraph(ts) {
options: new Map(),
reads: new Map(),
configHashes: new Map(),
faces: new Map(),
};
}

Expand Down Expand Up @@ -152,6 +154,64 @@ 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;

// A declaration file has nothing to emit, and TypeScript refuses to.
if (
typeof ts.transpileDeclaration !== "function" ||
/\.d\.[^/]*ts$/u.test(file)
) {
return undefined;
}

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"),
);
}

/**
* 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
Expand All @@ -171,10 +231,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<string>} */
const quiet = new Set();

for (const file of touched) {
const before = graph.files.get(file);

Expand All @@ -183,6 +250,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<string, string[]>} */
Expand Down Expand Up @@ -219,13 +307,26 @@ function affectedFiles(graph, files, changed) {

/** @type {Set<string>} */
const reached = new Set();
const queue = [...touched];
/** @type {Set<string>} */
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)));
Expand Down Expand Up @@ -281,14 +382,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
Expand Down
133 changes: 133 additions & 0 deletions test/import-graph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,139 @@ 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 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",
'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",
Expand Down
4 changes: 4 additions & 0 deletions types/import-graph.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export type Graph = {
* each config file and what it extends, hashed, as the signatures were last read under
*/
configHashes: Map<string, string>;
/**
* what each file that changed showed the files importing it, as the last change left it, hashed
*/
faces: Map<string, string>;
};
/**
* Which files a change can have changed what typed linting says of: the ones
Expand Down
Loading