diff --git a/.changeset/asset-imports-keep-graph.md b/.changeset/asset-imports-keep-graph.md new file mode 100644 index 0000000..a2b1032 --- /dev/null +++ b/.changeset/asset-imports-keep-graph.md @@ -0,0 +1,5 @@ +--- +"diagnostics-webpack-plugin": patch +--- + +Keep linting again only what a change reaches in a project importing stylesheets, images or other assets: such an import, which TypeScript resolves to nothing, counted as an import yet to resolve and made every rebuild lint every file again. diff --git a/.cspell.json b/.cspell.json index e398985..ee1ec69 100644 --- a/.cspell.json +++ b/.cspell.json @@ -26,7 +26,9 @@ "stylelint's", "stylelintcache", "stylelintignore", - "stylelintrc" + "stylelintrc", + "pcss", + "styl" ], "ignorePaths": [ "**/__snapshots__/**", diff --git a/README.md b/README.md index a59d455..7cd9ae9 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,8 @@ Which file imports which is read with the project's own TypeScript, through the drops it before webpack sees it — and this holds for every check, whatever tool 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 — lints every file again, as does a project without TypeScript. There, +nothing (a stylesheet, an image or another asset webpack bundles aside) — 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. diff --git a/src/import-graph.js b/src/import-graph.js index 8933662..51f0a35 100644 --- a/src/import-graph.js +++ b/src/import-graph.js @@ -17,7 +17,7 @@ import { toPosixPath } from "./utils.js"; * @property {string[]} resolved the files its imports resolve to * @property {boolean} unresolved whether a relative import resolved to nothing * @property {boolean} global whether it declares something every file can see - * @property {string[]} missing the relative imports that resolve to nothing, as written + * @property {string[]} missing the relative imports that resolve to nothing, as written, assets included */ /** @@ -41,6 +41,10 @@ import { toPosixPath } from "./utils.js"; * @property {Map} configHashes each config file and what it extends, hashed, as the signatures were last read under */ +// 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. const FALLBACK_OPTIONS = { allowJs: true, moduleResolution: 100 }; @@ -113,6 +117,9 @@ function importsOf(graph, file, text) { /** @type {string[]} */ const missing = []; + /** @type {string[]} */ + const assets = []; + for (const { fileName } of info.importedFiles) { const found = ts.resolveModuleName( fileName, @@ -121,8 +128,18 @@ function importsOf(graph, file, text) { ts.sys, ).resolvedModule; - if (found) resolved.push(toPosixPath(resolve(found.resolvedFileName))); - else if (fileName.startsWith(".")) missing.push(fileName); + if (found) { + resolved.push(toPosixPath(resolve(found.resolvedFileName))); + } else if (!fileName.startsWith(".")) { + continue; + } else if (ASSET.test(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. + assets.push(fileName); + } else { + missing.push(fileName); + } } for (const { fileName } of info.referencedFiles) { @@ -132,7 +149,8 @@ function importsOf(graph, file, text) { return { resolved, unresolved: missing.length > 0, - missing, + // Still part of what a signature is read from, and of nothing else. + missing: [...missing, ...assets], // `declare global` and `declare module` reach files that never import the // one declaring them. global: diff --git a/test/import-graph.test.js b/test/import-graph.test.js index cec262a..02d92f3 100644 --- a/test/import-graph.test.js +++ b/test/import-graph.test.js @@ -142,6 +142,37 @@ describe("import graph", () => { assert.strictEqual(affected(all, ["leaf.ts"]), undefined); }); + + it("should keep answering for what a change reaches where a stylesheet is imported", () => { + // Webpack bundles it; TypeScript resolves it to nothing, and nothing + // written later turns it into a type. + write( + "alone.ts", + 'import "./styles.css";\nimport logo from "./logo.svg?url";\nexport const a = logo;\n', + ); + + assert.deepStrictEqual(affected(all, ["leaf.ts"]), [ + "leaf.ts", + "middle.ts", + "top.ts", + ]); + }); + + it("should answer for every file where a stylesheet can be read through a declaration beside it", () => { + write( + "tsconfig.json", + JSON.stringify({ + compilerOptions: { + strict: true, + moduleResolution: "bundler", + allowArbitraryExtensions: true, + }, + }), + ); + write("alone.ts", 'import "./styles.css";\nexport const a = 1;\n'); + + assert.strictEqual(affected(all, ["leaf.ts"]), undefined); + }); }); describe("the graph a compiler keeps", () => { diff --git a/types/import-graph.d.ts b/types/import-graph.d.ts index efb5f66..ba237ef 100644 --- a/types/import-graph.d.ts +++ b/types/import-graph.d.ts @@ -17,7 +17,7 @@ export type Imports = { */ global: boolean; /** - * the relative imports that resolve to nothing, as written + * the relative imports that resolve to nothing, as written, assets included */ missing: string[]; };