chore: update lint rules

This commit is contained in:
2026-09-11 07:39:51 +05:00
parent 87bd4482a3
commit fa5f7660c7
12 changed files with 532 additions and 79 deletions
+28
View File
@@ -0,0 +1,28 @@
/**
* Local ESLint plugin "conventions".
*
* Cross-cutting code conventions that the recommended rule sets don't enforce
* (and plain @typescript-eslint rules cannot express in one shot):
*
* - interface-props: Svelte 5 props always go through a local `interface
* Props` + `let { ... }: Props = $props()` (no inline generics, no inline
* type imports, no untyped destructuring);
* - no-string-union-alias: string-literal union aliases (`type Kind = 'a' |
* 'b'`) are banned in favor of a single `as const` object + indexed access,
* so literal sets live in exactly one place.
*
* Rules are AST-only (no filesystem), see the tests in eslint-plugins/__tests__.
*/
import interfaceProps from "./interface-props.js";
import noStringUnionAlias from "./no-string-union-alias.js";
export default {
meta: {
name: "conventions",
version: "0.1.0",
},
rules: {
"interface-props": interfaceProps,
"no-string-union-alias": noStringUnionAlias,
},
};
@@ -0,0 +1,135 @@
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props`.
//
// Convention (AGENTS.md): every typed props of a component is described by a
// local `interface Props`, and the props are destructured with the annotation:
//
// interface Props {
// label: string;
// accent?: boolean;
// children?: Snippet;
// }
// let { label, accent = false, children }: Props = $props();
//
// Banned instead:
// - the inline generic `$props<{ ... }>()` — hard to read and splits the
// type away from the file structure;
// - untyped destructuring `let { ... } = $props()`;
// - binding the whole props object (`const props = $props()`);
// - any props type name other than the local `interface Props`.
//
// Inline `import('...')` type queries are NOT checked here: they are already
// banned by @typescript-eslint/consistent-type-imports.
//
// The check is purely syntactic (AST-level). Svelte's runes model guarantees
// $props() only exists in instance <script> blocks, so no scope/type info is
// needed. Interfaces are collected from every <script> (module + instance).
import { isInsideScriptElement } from "./utils.js";
export default {
meta: {
type: "suggestion",
fixable: "code",
docs: {
description:
"Require a local `interface Props` and `let { ... }: Props = $props()` for Svelte 5 props; ban inline generics, untyped destructuring and non-local props type names.",
category: "Svelte conventions",
recommended: true,
},
schema: [],
messages: {
inlineGeneric:
"Avoid the inline generic `$props<T>()`; declare a local `interface Props` and destructure it: `let { ... }: Props = $props()`.",
untypedDestructure:
"Annotate the props destructuring with a local `interface Props`: `let { ... }: Props = $props()`.",
noDestructure:
"Destructure props with `let { ... }: Props = $props()` instead of binding the whole `$props()` object.",
notNamedProps:
"The props type must be the local `interface Props` (found '{{name}}').",
inlineObjectType:
"Declare a local `interface Props` instead of an inline object props type.",
missingInterface:
"No local `interface Props` is declared in this component; add one and use it as the props type.",
},
},
create(context) {
const interfaces = new Set();
return {
Program(node) {
for (const child of node.body) {
if (child.type !== "SvelteScriptElement") continue;
for (const stmt of child.body ?? []) {
if (stmt.type !== "TSInterfaceDeclaration") continue;
interfaces.add(stmt.id.name);
}
}
},
CallExpression(node) {
if (
node.callee.type !== "Identifier" ||
node.callee.name !== "$props"
) {
return;
}
if (!isInsideScriptElement(node)) return;
// Inline generic $props<{ ... }>() is banned outright.
if (node.typeArguments?.params?.length) {
context.report({ node: node.callee, messageId: "inlineGeneric" });
return;
}
let declarator = node.parent;
while (declarator && declarator.type !== "VariableDeclarator") {
declarator = declarator.parent;
}
if (!declarator) return;
const id = declarator.id;
if (id.type === "ObjectPattern") {
const annotation = id.typeAnnotation?.typeAnnotation ?? null;
if (annotation) {
if (annotation.type === "TSTypeReference") {
const typeName = annotation.typeName;
if (!typeName || typeName.type !== "Identifier") return;
if (typeName.name !== "Props") {
context.report({
node: typeName,
messageId: "notNamedProps",
data: { name: typeName.name },
});
} else if (!interfaces.has("Props")) {
context.report({
node: typeName,
messageId: "missingInterface",
});
}
} else if (annotation.type !== "TSImportType") {
context.report({
node: annotation,
messageId: "inlineObjectType",
});
}
} else {
context.report({
node: id,
messageId: "untypedDestructure",
...(interfaces.has("Props")
? {
fix: (fixer) => fixer.insertTextAfter(id, ": Props"),
}
: {}),
});
}
return;
}
if (id.type === "Identifier") {
context.report({ node: id, messageId: "noDestructure" });
}
},
};
},
};
@@ -0,0 +1,64 @@
// Rule: NO STRING-LITERAL UNION TYPE ALIASES.
//
// A type alias whose members are all string literals (`type Kind = 'a' | 'b'`)
// duplicates the literal set: the same set is re-typed in many places, and new
// "derived" aliases (`type Kind2 = 'a' | 'c'`) appear that drift from the
// source. Prefer a single `as const` object as the source of truth and derive
// the type from it with indexed access.
//
// const KIND = { a: ..., b: ..., c: ... } as const;
// type Kind = (typeof KIND)[keyof typeof KIND];
//
// Only TSTypeAliasDeclaration is checked — inline unions in parameter or
// property types (one-off uses) are left alone.
/** True when the union and all its (possibly nested) members are string literals. */
function allStringLiterals(/** @type {any} */ union) {
for (const member of union.types) {
if (member.type === "TSUnionType") {
if (!allStringLiterals(member)) return false;
} else if (member.type === "TSLiteralType") {
const literal = member.literal;
if (!(literal.type === "Literal" && typeof literal.value === "string")) {
return false;
}
} else {
return false;
}
}
return true;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban string-literal union type aliases in favor of an `as const` object plus `(typeof X)[keyof typeof X]`.",
category: "TypeScript conventions",
recommended: true,
},
schema: [],
messages: {
stringUnion:
"Prefer one `as const` object over the string-literal union alias '{{name}}' — literal sets duplicated across types or files drift apart. Derive the type instead: `const {{constName}} = {...} as const; type {{name}} = (typeof {{constName}})[keyof typeof {{constName}}]`.",
},
},
create(context) {
return {
TSTypeAliasDeclaration(node) {
const annotation = node.typeAnnotation;
if (!annotation || annotation.type !== "TSUnionType") return;
if (!allStringLiterals(annotation)) return;
context.report({
node: node.id,
messageId: "stringUnion",
data: {
name: node.id.name,
constName: node.id.name.toUpperCase(),
},
});
},
};
},
};
+15
View File
@@ -0,0 +1,15 @@
// Shared helpers for the conventions plugin (web/eslint-plugins/conventions).
/**
* True when the node sits somewhere under a Svelte <script> block
* (SvelteScriptElement). Used to limit $props() checks to script code.
* @param {any} node
*/
export function isInsideScriptElement(node) {
let cursor = node.parent;
while (cursor && cursor.type !== "Program") {
if (cursor.type === "SvelteScriptElement") return true;
cursor = cursor.parent;
}
return false;
}