mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-28 16:00:38 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984eda8847 | ||
|
|
03d801a040 |
@@ -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(">, <") },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user