Compare commits

...
Author SHA1 Message Date
Bram KragtenandClaude Opus 5 984eda8847 Do not let an import path pass for an element usage
The rule blanked out only the side-effect imports before searching for the tag,
so any other declaration importing from the same module left the tag in the
searched text. `import "./ha-card"; import type { HaCard } from "./ha-card";`
hid a dead registration, even though an erased type import registers nothing.

Blank every import declaration instead: a module path is never a place where an
element gets rendered. Found one more real case,
hui-areas-dashboard-strategy-editor, and brings the backlog to 337 imports in
233 files.

Reported by Copilot on home-assistant/frontend#54206.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-15 16:41:49 +02:00
Bram KragtenandClaude Opus 5 03d801a040 Add ha/no-unused-element-import lint rule
Nothing in the toolchain can see a dead `import "../../components/ha-card";`:
unused-imports has no binding to flag, and lit-analyzer only guards the opposite
direction with no-missing-import. A registration import therefore outlives the
template that needed it, and nothing reports it.

The rule resolves each specifier-less relative import, reads the tag its target
registers, and reports when neither the importing file nor -- for a gallery page
-- its sibling .markdown mentions that tag. It auto-exempts the cases where the
import is the registration: entry points mounting a root element, the Lovelace
create-element registries, and files that build the tag from a literal prefix at
runtime. Two patterns it cannot infer -- a subclass registering components for
an inherited template, and demo stubs registering `custom:` cards -- need a
disable comment stating why. That is a feature: those imports are invisible
dependencies and deserve the sentence.

Not autofixable: removing such an import is only safe once the file that renders
the element imports it directly, which the rule cannot see.

Registered but "off" in eslint.config.mjs. It currently reports 336 imports in
232 files and lint:eslint runs with --max-warnings=0, so enabling it now would
fail CI; `yarn lint:element-imports` turns it on as a warning to work through
the list. Flip it to "error" and drop that script once the list is empty.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-15 16:35:33 +02:00
5 changed files with 313 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
// @ts-check
import { noUnusedElementImport } from "./no-unused-element-import.mjs";
export default {
meta: { name: "eslint-plugin-ha" },
rules: {
"no-unused-element-import": noUnusedElementImport,
},
};
@@ -0,0 +1,184 @@
// @ts-check
import fs from "node:fs";
import path from "node:path";
const TAG_PATTERN =
/@customElement\(\s*["'`]([a-z0-9-]+)["'`]|customElements\.define\(\s*["'`]([a-z0-9-]+)["'`]/g;
const EXTENSIONS = /\.(ts|js)$/;
// Entry points register the root element that the served HTML renders.
const ENTRY_POINT = /(^|[\\/])(entrypoints[\\/]|entrypoint\.(ts|js)$)/;
// These resolve a tag from config at runtime, so they register every candidate
// they may be asked for.
const RUNTIME_REGISTRY =
/[\\/]create-element[\\/]|createLovelaceElement\(|getLovelaceElementClass\(/;
/** @type {Map<string, string[]>} */
const tagCache = new Map();
/** Tags the module at `file` registers when imported. */
const registeredTags = (file) => {
let tags = tagCache.get(file);
if (!tags) {
try {
const source = fs.readFileSync(file, "utf8");
tags = [...source.matchAll(TAG_PATTERN)].map((m) => m[1] || m[2]);
} catch {
tags = [];
}
tagCache.set(file, tags);
}
return tags;
};
const isFile = (candidate) => {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
};
const resolveModule = (fromFile, request) => {
const base = path.resolve(path.dirname(fromFile), request);
return [
base,
`${base}.ts`,
`${base}.js`,
base.replace(/\.js$/, ".ts"),
path.join(base, "index.ts"),
path.join(base, "index.js"),
].find((candidate) => EXTENSIONS.test(candidate) && isFile(candidate));
};
// `hui-${type}-card` or "state-card-" + domain never spell the tag out.
const buildsTagAtRuntime = (source, tag) => {
const segments = tag.split("-");
for (let end = 1; end < segments.length; end++) {
const prefix = `${segments.slice(0, end).join("-")}-`;
if (
source.includes(`\`${prefix}\${`) ||
source.includes(`"${prefix}" +`) ||
source.includes(`'${prefix}' +`)
) {
return true;
}
}
return false;
};
// Plain substring matching, so <ha-icon-button> counts as a use of ha-icon.
// That direction is deliberate: it misses dead imports rather than asking for
// a removal that breaks registration.
const usesTag = (body, tag) => body.includes(tag);
// A gallery page registers the elements its sibling markdown demos render, so
// that markdown is part of what the module is used by.
const siblingMarkup = (file) => {
const markdown = file.replace(EXTENSIONS, ".markdown");
try {
return fs.readFileSync(markdown, "utf8");
} catch {
return "";
}
};
/** Blanks out the given ranges so an import path cannot count as a usage. */
const withoutRanges = (source, ranges) => {
let result = source;
for (const [start, end] of ranges) {
result =
result.slice(0, start) + " ".repeat(end - start) + result.slice(end);
}
return result;
};
/**
* Side-effect imports register a custom element. ESLint's unused-import rules
* cannot judge them — there is no binding to be unused — so a registration
* outlives the template that needed it and nothing reports the dead import.
*
* Deliberately not fixable: removing the import is only safe once every file
* that renders the element imports it itself, which this rule cannot see.
*
* Off in eslint.config.mjs while a backlog of those paired cases remains;
* `yarn lint:element-imports` enables it as a warning to list them. Flip it to
* "error" there once the list is empty.
*/
export const noUnusedElementImport = {
meta: {
type: "suggestion",
docs: {
description:
"Disallow side-effect imports of a custom element the importing file never uses",
},
schema: [],
messages: {
unused:
'"{{request}}" only registers <{{tags}}>, which this file never uses. Remove the import, or disable this rule with the reason the element must be registered here.',
},
},
create(context) {
const filename = context.filename;
if (!EXTENSIONS.test(filename) || ENTRY_POINT.test(filename)) {
return {};
}
return {
Program(program) {
const imports = program.body.filter(
(node) => node.type === "ImportDeclaration"
);
const sideEffectImports = imports.filter(
(node) =>
node.specifiers.length === 0 &&
typeof node.source.value === "string" &&
node.source.value.startsWith(".")
);
if (!sideEffectImports.length) {
return;
}
const source = context.sourceCode.getText();
if (RUNTIME_REGISTRY.test(filename) || RUNTIME_REGISTRY.test(source)) {
return;
}
// Every import declaration, not just the side-effect ones: a module
// path is never a usage, and `import type { HaCard } from "./ha-card"`
// would otherwise pass off its own path as one.
const body =
withoutRanges(
source,
imports.map((node) => node.range)
) + siblingMarkup(filename);
for (const node of sideEffectImports) {
const request = node.source.value;
const target = resolveModule(filename, request);
if (!target) {
continue;
}
// No tags means no registration to judge: styles, polyfills, mocks.
const tags = registeredTags(target);
if (
!tags.length ||
tags.some(
(tag) => usesTag(body, tag) || buildsTagAtRuntime(body, tag)
)
) {
continue;
}
context.report({
node,
messageId: "unused",
data: { request, tags: tags.join(">, <") },
});
}
},
};
},
};
+6
View File
@@ -12,6 +12,7 @@ import { configs as wcConfigs } from "eslint-plugin-wc";
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
import ha from "./build-scripts/eslint-rules/index.mjs";
const rspackConfigPath = fileURLToPath(
new URL("./rspack.config.cjs", import.meta.url)
@@ -33,6 +34,7 @@ export default tseslint.config(
{
plugins: {
"unused-imports": unusedImports,
ha,
},
languageOptions: {
@@ -212,6 +214,10 @@ export default tseslint.config(
],
"unused-imports/no-unused-imports": "error",
// Registered so disable comments resolve. Off here because the
// existing backlog would fail lint:eslint's --max-warnings=0; run
// lint:element-imports to see it.
"ha/no-unused-element-import": "off",
"lit/attribute-names": "error",
"lit/attribute-value-entities": "off",
"lit/no-template-map": "off",
+1
View File
@@ -14,6 +14,7 @@
"format:prettier": "prettier . --cache --write",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
"format": "yarn run format:eslint && yarn run format:prettier",
@@ -0,0 +1,112 @@
/**
* @vitest-environment node
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { RuleTester } from "eslint";
import tseslint from "typescript-eslint";
import { afterAll, describe, it } from "vitest";
import { noUnusedElementImport } from "../../build-scripts/eslint-rules/no-unused-element-import.mjs";
// The rule resolves imports and reads the target from disk, so the fixtures
// have to be real files.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "element-import-"));
const write = (name, contents) => {
fs.writeFileSync(path.join(dir, name), contents);
return path.join(dir, name);
};
write("ha-thing.js", '@customElement("ha-thing")\nclass HaThing {}');
write("ha-thing-row.js", 'customElements.define("ha-thing-row", HaThingRow);');
write("styles.js", "export const styles = css``;");
write("gallery-page.js", 'import "./ha-thing";');
write("gallery-page.markdown", "# Thing\n\n<ha-thing></ha-thing>");
const file = (name) => path.join(dir, name);
afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
// RuleTester needs the test hooks injected: vitest does not expose globals.
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
ecmaVersion: 2022,
sourceType: "module",
},
});
describe("no-unused-element-import", () => {
ruleTester.run("no-unused-element-import", noUnusedElementImport, {
valid: [
{
name: "element used in a template",
filename: file("uses-it.js"),
code: 'import "./ha-thing";\nhtml`<ha-thing></ha-thing>`;',
},
{
name: "target registers no element",
filename: file("imports-styles.js"),
code: 'import "./styles";',
},
{
name: "tag built at runtime from a literal prefix",
filename: file("builds-tag.js"),
code: `import "./ha-thing-row";\nconst tag = \`ha-thing-\${type}\`;`,
},
{
name: "element rendered by the page's sibling markdown",
filename: file("gallery-page.js"),
code: 'import "./ha-thing";',
},
{
name: "entry point mounting the root element",
filename: file("entrypoint.js"),
code: 'import "./ha-thing";',
},
{
name: "runtime registry resolving tags from config",
filename: file("create-row.js"),
code: 'import "./ha-thing";\ncreateLovelaceElement("row", config, TYPES);',
},
{
name: "import that does not resolve to a file",
filename: file("unresolved.js"),
code: 'import "./nope";',
},
{
name: "bare package import",
filename: file("package-import.js"),
code: 'import "lit";',
},
],
invalid: [
{
name: "element never referenced",
filename: file("never-uses-it.js"),
code: 'import "./ha-thing";\nhtml`<div></div>`;',
errors: [{ messageId: "unused" }],
},
{
// An erased type import registers nothing, so the side-effect import is
// still the only registration -- and still dead.
name: "type import from the same module is not a usage",
filename: file("type-import.ts"),
code: 'import "./ha-thing";\nimport type { HaThing } from "./ha-thing";\nhtml`<div></div>`;',
errors: [{ messageId: "unused" }],
},
{
// Without blanking the import paths, "ha-thing-row" would look used.
name: "tag mentioned only by its own import path",
filename: file("only-path.js"),
code: 'import "./ha-thing";\nimport "./ha-thing-row";\nhtml`<ha-thing></ha-thing>`;',
errors: [{ messageId: "unused" }],
},
],
});
});