chore: add lint rules, fix script\configs

This commit is contained in:
2026-10-08 04:51:15 +05:00
parent 76039f7bf3
commit 0e8c2478a1
32 changed files with 753 additions and 272 deletions
+28
View File
@@ -131,6 +131,34 @@ report-only (`warn`), baseline — 0 срабатываний.
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
## Плагин conventions: текстовые конвенции
- `conventions/ascii-only` — в файлах, заданных scope'ом конфига (`*.test.ts`,
`*.spec.ts`, `e2e/**`), только ASCII: типографика и неанглийский текст в
`describe`/`it` и ожиданиях мешают поиску и скрывают хардкод локализованных
строк. Осознанные глифы (например `×` в пайплайне) — через опцию `allowChars`
в `eslint.config.js`. Один репорт на строку.
- `conventions/comments-english` — комментарии только на английском, по **белому
списку**: комментарий обязан быть ASCII, поэтому ловятся любые письменности
(кириллица, CJK, арабский), а не один конкретный алфавит; осознанные глифы —
через опцию `allowChars`. Строковые литералы не трогаем — локализованные
значения легальны в словарях.
- `conventions/comment-format` — два мягких потолка: `maxBlockLines` (по
умолчанию 5) строк на один блок комментариев и `maxRatio` (по умолчанию 0.15)
— доля комментарных строк от непустых строк файла; density проверяется только
в файлах от `minLines` непустых строк (в конфиге 100), иначе любая шапка в
маленьком файле даёт ложное превышение. Превышение — сигнал к рефакторингу
кода, а не к сжатию комментария.
Scope правил комментариев — не только `src/`: сами плагины, `scripts/` и
`*.config.*` живут по тем же конвенциям (dogfooding, их baseline вычищен до
нуля). Фикстуры и тесты правил (`__fixtures__/`, `__tests__/`) — намеренные
входные кейсы и из scope исключены. В `src/` baseline ненулевой, поэтому все три
правила введены warn-only и в `lint:all` отключены через override в
`scripts/lint-all.mjs`, пока не сделана чистка (`docs/backlog.md`, раздел «Тесты
и lint»). Тесты: `__tests__/ascii-only.test.ts`,
`__tests__/comments-english.test.ts`, `__tests__/comment-format.test.ts`.
## Токены-префиксы (целевой словарь дизайна)
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
@@ -0,0 +1,79 @@
// Tests for the conventions/ascii-only rule
// (web/eslint-plugins/conventions/ascii-only.js).
//
// Pure file-content check, no filesystem: standard RuleTester with the
// tseslint parser. The scoping to test files lives in eslint.config.js, so
// filenames here are arbitrary.
import { RuleTester } from "eslint";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import asciiOnly from "../conventions/ascii-only.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
tester.run("ascii-only", asRuleModule(asciiOnly), {
valid: [
{ code: "const x = 1;", filename: "a.test.ts" },
{
code: "describe('resize pipeline', () => {});",
filename: "a.test.ts",
},
{
// Glyphs listed in allowChars pass.
code: "expect(label).toBe('512×512');",
filename: "a.test.ts",
options: [{ allowChars: ["×"] }],
},
],
invalid: [
{
code: "it('ресайзит картинку', () => {});",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "// arrow → here\nconst x = 1;",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Em dash and ellipsis on one line collapse to a single report.
code: "it('a — b … c', () => {});",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Two offending lines produce two reports.
code: "// über\n// café\nconst x = 1;",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
},
{
// allowChars does not whitelist other glyphs on the same line.
code: "expect(s).toBe('512×512 → done');",
filename: "a.test.ts",
options: [{ allowChars: ["×"] }],
errors: [{ messageId: "nonAscii" }],
},
{
// Control characters (here: vertical tab) are not printable ASCII.
code: "const s = '\x0B';",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// DEL (U+007F) is not printable either.
code: "const s = '\x7F';",
filename: "a.test.ts",
errors: [{ messageId: "nonAscii" }],
},
],
});
@@ -0,0 +1,98 @@
// Tests for the conventions/comment-format rule
// (web/eslint-plugins/conventions/comment-format.js).
//
// Pure AST check (comment nodes + line counts), standard RuleTester.
import { RuleTester } from "eslint";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import commentFormat from "../conventions/comment-format.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
const longBlock = [
"// one",
"// two",
"// three",
"// four",
"// five",
"// six",
"const x = 1;",
].join("\n");
tester.run("comment-format", asRuleModule(commentFormat), {
// Density is off (maxRatio: 1) in the block-focused cases: tiny snippets
// are comment-dominated by construction.
valid: [
{
code: "// why\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
code: "// a\n// b\n// c\n// d\n// e\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
// Blank line breaks the run: two 3-line blocks, both under the cap.
code: "// a\n// b\n// c\n\n// d\n// e\n// f\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
// Trailing inline comment does not extend the block above it.
code: "// a\n// b\nconst x = 1; // tail",
filename: "a.ts",
options: [{ maxRatio: 1 }],
},
{
// A wider block cap admits the same comment.
code: longBlock,
filename: "a.ts",
options: [{ maxBlockLines: 6, maxRatio: 1 }],
},
{
// Density is skipped below minLines: small files are exempt.
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
filename: "a.ts",
options: [{ maxBlockLines: 5, maxRatio: 0.4, minLines: 10 }],
},
],
invalid: [
{
code: longBlock,
filename: "a.ts",
options: [{ maxRatio: 1 }],
errors: [{ messageId: "blockTooLong" }],
},
{
// A single block comment counts its full line span.
code: "/*\n1\n2\n3\n4\n5\n6\n*/\nconst x = 1;",
filename: "a.ts",
options: [{ maxRatio: 1 }],
errors: [{ messageId: "blockTooLong" }],
},
{
// Density: 2 comment lines against 2 code lines is 50%.
code: "// a\n// b\nconst x = 1;\nconst y = 2;",
filename: "a.ts",
options: [{ maxBlockLines: 5, maxRatio: 0.4 }],
errors: [{ messageId: "tooDense" }],
},
{
// Both violations in one file produce two reports.
code: longBlock + "\n// extra",
filename: "a.ts",
options: [{ maxBlockLines: 5, maxRatio: 0.5 }],
errors: [{ messageId: "blockTooLong" }, { messageId: "tooDense" }],
},
],
});
@@ -0,0 +1,96 @@
// Tests for the conventions/comments-english rule
// (web/eslint-plugins/conventions/comments-english.js).
//
// Pure AST check over comment nodes with an ASCII whitelist; string literals
// are ignored by construction (they are not comments), which the cases below
// pin down.
import { RuleTester } from "eslint";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import commentsEnglish from "../conventions/comments-english.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
tester.run("comments-english", asRuleModule(commentsEnglish), {
valid: [
{ code: "// resize the canvas\nconst x = 1;", filename: "a.ts" },
{ code: "/* multi\nline */\nconst x = 1;", filename: "a.ts" },
{
// Localized strings are dictionary content, not comments.
code: "const title = 'Пример';",
filename: "a.ts",
},
{
// Glyphs listed in allowChars pass.
code: "// size 512×512\nconst x = 1;",
filename: "a.ts",
options: [{ allowChars: ["×"] }],
},
{
// A tab inside a comment is legitimate whitespace.
code: "//\tnote\nconst x = 1;",
filename: "a.ts",
},
],
invalid: [
{
code: "// ресайз холста\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Whitelist catches scripts beyond Cyrillic too.
code: "// 调整画布大小\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "// تغيير حجم اللوحة\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// Typography in an otherwise English comment is still non-ASCII.
code: "// resize — then crop\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "/* много\nстрочный */\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
code: "const x = 1; // хвостовой комментарий",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
{
// One report per offending comment, not per file.
code: "// раз\n// два\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }],
},
{
// allowChars does not whitelist other glyphs in the same comment.
code: "// 512×512 → done\nconst x = 1;",
filename: "a.ts",
options: [{ allowChars: ["×"] }],
errors: [{ messageId: "nonAscii" }],
},
{
// Control characters (here: vertical tab) are not printable ASCII.
code: "// \x0B note\nconst x = 1;",
filename: "a.ts",
errors: [{ messageId: "nonAscii" }],
},
],
});
@@ -0,0 +1,61 @@
// Rule: ASCII-ONLY SOURCE (scoped to test files via eslint.config.js).
// Non-ASCII in test names and expectations hurts grep-ability and hides
// hardcoded localized strings; intentional glyphs go to the allowChars
// option. File-content based (not AST), one report per line.
// Details: web/eslint-plugins/README.md.
const DEFAULT_ALLOW = [];
export default {
meta: {
type: "suggestion",
docs: {
description:
"Require ASCII-only source in scoped files (tests); exceptions go to the allowChars option.",
category: "Conventions",
recommended: false,
},
schema: [
{
type: "object",
properties: {
allowChars: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 1 },
uniqueItems: true,
},
},
additionalProperties: false,
},
],
messages: {
nonAscii:
"Non-ASCII character {{chars}} on this line. Tests are written in plain ASCII English; keep intentional glyphs in the rule's allowChars option.",
},
},
create(context) {
const allowed = new Set(context.options[0]?.allowChars ?? DEFAULT_ALLOW);
return {
Program() {
const sourceCode = context.sourceCode;
const text = sourceCode.getText();
const reportedLines = new Set();
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
for (const match of text.matchAll(/[^\x09\x0A\x0D\x20-\x7E]/g)) {
const char = match[0];
if (allowed.has(char)) continue;
const loc = sourceCode.getLocFromIndex(match.index);
if (reportedLines.has(loc.line)) continue;
reportedLines.add(loc.line);
context.report({
loc,
messageId: "nonAscii",
data: {
chars: `'${char}' (U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`,
},
});
}
},
};
},
};
@@ -0,0 +1,104 @@
// Rule: COMMENT FORMAT: bounded blocks and bounded density.
// maxBlockLines caps one comment block; maxRatio caps the share of comment
// lines in files with minLines+ non-blank lines. Warn-only tripwires:
// excess means refactor the code, not squeeze the comment.
// Details: web/eslint-plugins/README.md.
function lineSpan(comment) {
return comment.loc.end.line - comment.loc.start.line + 1;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Cap comment block length and overall comment density per file.",
category: "Conventions",
recommended: false,
},
schema: [
{
type: "object",
properties: {
maxBlockLines: { type: "integer", minimum: 1 },
maxRatio: { type: "number", minimum: 0, maximum: 1 },
minLines: { type: "integer", minimum: 0 },
},
additionalProperties: false,
},
],
messages: {
blockTooLong:
"Comment block is {{lines}} lines (limit {{max}}). Explain 'why' briefly; move 'what' into names, structure, or a linked doc.",
tooDense:
"Comments take {{percent}}% of this file (limit {{max}}%). Prefer extracting functions/modules over narrating the code.",
},
},
create(context) {
const maxBlockLines = context.options[0]?.maxBlockLines ?? 5;
const maxRatio = context.options[0]?.maxRatio ?? 0.15;
const minLines = context.options[0]?.minLines ?? 0;
return {
Program(node) {
const sourceCode = context.sourceCode;
const comments = sourceCode.getAllComments();
if (comments.length === 0) return;
const commentLines = new Set();
let blockStart = comments[0];
let blockEndLine = comments[0].loc.end.line;
let blockLines = lineSpan(comments[0]);
const flushBlock = () => {
if (blockLines > maxBlockLines) {
context.report({
loc: blockStart.loc,
messageId: "blockTooLong",
data: { lines: String(blockLines), max: String(maxBlockLines) },
});
}
};
for (let i = 0; i < comments.length; i++) {
const comment = comments[i];
for (let l = comment.loc.start.line; l <= comment.loc.end.line; l++) {
commentLines.add(l);
}
if (i === 0) continue;
// Adjacent `//` lines form one block; anything else starts a new one.
if (
comment.type === "Line" &&
comments[i - 1].type === "Line" &&
comment.loc.start.line === blockEndLine + 1
) {
blockEndLine = comment.loc.end.line;
blockLines += 1;
} else {
flushBlock();
blockStart = comment;
blockEndLine = comment.loc.end.line;
blockLines = lineSpan(comment);
}
}
flushBlock();
const totalLines = sourceCode.lines.filter(
(l) => l.trim() !== "",
).length;
const ratio = commentLines.size / totalLines;
if (totalLines >= minLines && ratio > maxRatio) {
context.report({
loc: node.loc,
messageId: "tooDense",
data: {
percent: (ratio * 100).toFixed(1),
max: (maxRatio * 100).toFixed(1),
},
});
}
},
};
},
};
@@ -0,0 +1,63 @@
// Rule: COMMENTS IN ENGLISH ONLY. Whitelist, not blacklist: a comment must
// be plain ASCII, so Arabic/CJK/Korean are caught just like Cyrillic.
// Intentional glyphs go to the allowChars option. String literals are NOT
// checked: localized dictionary values legitimately contain other scripts.
// Details: web/eslint-plugins/README.md.
export default {
meta: {
type: "suggestion",
docs: {
description:
"Require comments to be written in English (ASCII whitelist; exceptions via allowChars).",
category: "Conventions",
recommended: false,
},
schema: [
{
type: "object",
properties: {
allowChars: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 1 },
uniqueItems: true,
},
},
additionalProperties: false,
},
],
messages: {
nonAscii:
"Comment contains non-ASCII text ({{chars}}). Write comments in English; localized user-facing strings belong in lib/i18n dictionaries.",
},
},
create(context) {
const allowed = new Set(context.options[0]?.allowChars ?? []);
return {
Program() {
for (const comment of context.sourceCode.getAllComments()) {
const offenders = new Set();
for (const match of comment.value.matchAll(
// eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes
/[^\x09\x0A\x0D\x20-\x7E]/g,
)) {
if (!allowed.has(match[0])) offenders.add(match[0]);
}
if (offenders.size === 0) continue;
context.report({
loc: comment.loc,
messageId: "nonAscii",
data: {
chars: [...offenders]
.map(
(c) =>
`'${c}' (U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`,
)
.join(", "),
},
});
}
},
};
},
};
+9 -13
View File
@@ -1,18 +1,11 @@
/**
* 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__.
* Local ESLint plugin "conventions": cross-cutting code conventions the
* recommended rule sets do not enforce. Rule details:
* web/eslint-plugins/README.md.
*/
import asciiOnly from "./ascii-only.js";
import commentFormat from "./comment-format.js";
import commentsEnglish from "./comments-english.js";
import interfaceProps from "./interface-props.js";
import noStringUnionAlias from "./no-string-union-alias.js";
@@ -22,6 +15,9 @@ export default {
version: "0.1.0",
},
rules: {
"ascii-only": asciiOnly,
"comment-format": commentFormat,
"comments-english": commentsEnglish,
"interface-props": interfaceProps,
"no-string-union-alias": noStringUnionAlias,
},
@@ -1,28 +1,8 @@
// 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).
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props` (AGENTS.md).
// Banned: the inline generic `$props<T>()`, untyped destructuring, binding
// the whole `$props()` object, non-local props type names. Purely syntactic
// check; inline import('...') types are already covered by
// @typescript-eslint/consistent-type-imports.
import { isInsideScriptElement } from "./utils.js";
@@ -1,16 +1,7 @@
// 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.
// Rule: NO STRING-LITERAL UNION TYPE ALIASES. A `type Kind = 'a' | 'b'`
// alias duplicates the literal set across files and derived aliases drift
// from the source; prefer one `as const` object + indexed access. Only
// TSTypeAliasDeclaration is checked. Details: web/eslint-plugins/README.md.
/** True when the union and all its (possibly nested) members are string literals. */
function allStringLiterals(/** @type {any} */ union) {
+4 -14
View File
@@ -16,31 +16,21 @@ export const SIZE_PROPS =
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/;
// =====================================================================
// SHORTHAND properties that accept BOTH a color and a size. The browser
// assigns their sub-properties by value type at runtime (length -> width,
// color -> ...-color), so they cannot be category-checked positionally.
// border -> border-width + border-color
// outline -> outline-width + outline-color
// text-decoration -> text-decoration-line/-color/...
// column-rule -> column-rule-width + column-rule-color
// The longhands they expand to (border-width, border-color, ...) are already
// covered individually by SIZE_PROPS / COLOR_PROPS.
// SHORTHANDS taking BOTH a color and a size: the browser assigns
// sub-properties by value type, so no positional category check. Their
// longhands are covered by SIZE_PROPS / COLOR_PROPS individually.
// =====================================================================
export const MIXED_PROPS =
/^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/;
// =====================================================================
// Properties that carry a DURATION (ms/s) — transitions/animations.
// Properties that carry a DURATION (ms/s): transitions/animations.
// =====================================================================
export const DURATION_PROPS =
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
// =====================================================================
// A hardcoded COLOR literal: hex / color functions / named colors.
// Regex legend:
// - hex: #[0-9a-fA-F]{3,8}\b — #fff / #112233 / #1234
// - funcs: (rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)(
// - names: common CSS color names on a word boundary
// =====================================================================
export const COLOR_LITERAL =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
@@ -1,9 +1,7 @@
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS.
// A size property (padding, gap, font-size ...) must use a SIZE token
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property
// (color, background, border-color ...) must use a COLOR token
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
// is a sign the wrong token is being reused.
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS. A size property must use
// a SIZE token (--space-*, --size-*, ...), a color property a COLOR token
// (--color-*, --brand-*). Crossing categories (padding: var(--color-x))
// means the wrong token is being reused.
import {
COLOR_PROPS,
@@ -56,12 +54,10 @@ export default {
const prop = decl.prop ?? "";
const value = decl.value ?? "";
// Mixed shorthands (border, outline, text-decoration,
// column-rule) legitimately take BOTH a size and a color.
// The browser assigns sub-properties by value type at runtime,
// not by position, so skip category checks for them.
// Their longhands (border-width, border-color, ...) are still
// covered individually.
// Mixed shorthands (border, outline, ...) legitimately take
// BOTH a size and a color: the browser assigns sub-properties
// by value type, not by position, so skip category checks.
// Their longhands are still covered individually.
if (MIXED_PROPS.test(prop)) return;
const isSizeProp = SIZE_PROPS.test(prop);
@@ -1,6 +1,6 @@
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
// Everything visual in a Svelte <style> block must come from design tokens
// (var(--...)); direct literals are banned. color-mix() is banned too — its
// (var(--...)); direct literals are banned. color-mix() is banned too: its
// result must be tokenized in the design CSS file.
import {
@@ -14,8 +14,7 @@ import {
} from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
// mistaken for literals.
// Strip var(...) bodies so tokens inside are never mistaken for literals.
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
export default {
@@ -64,16 +63,12 @@ export default {
});
};
// =====================================================================
// 3) NO HARDCODED BREAKPOINTS IN @media
// @media conditions must use --bp-* tokens, not raw lengths.
// Regex: (max|min)-width followed by a NON-var(() number.
// =====================================================================
// --- NO HARDCODED BREAKPOINTS IN @media ---
// (max|min)-width followed by a NON-var( number is a raw length.
const BREAKPOINT_RE =
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
// Custom properties do not resolve inside @media conditions, so any
// var(--bp-*) inside @media is a latent bug — the block never applies.
// Custom properties do not resolve in @media: var(--bp-*) there is a latent bug.
const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g;
// Sub-rule 6: color-mix() is banned anywhere in a component style.
@@ -123,12 +118,10 @@ export default {
// Only inspect known layout properties from here on.
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
// Sub-rule 2: sizes.
// Fire only on a real remaining px/rem/em token — percentages
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
// The only legal absolute length is a 0px reset line.
// MIXED_PROPS (border/outline shorthands) are checked for their
// SIZE half too — a border width must come from var(--size-*).
// Sub-rule 2: sizes. Fire only on a real px/rem/em token:
// percentages and unitless values stay legal, a 0px reset is
// the only legal absolute length. MIXED_PROPS shorthands are
// checked for their SIZE half too.
if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const reported = new Set();
@@ -141,9 +134,8 @@ export default {
}
}
// Sub-rule 1: colors.
// Remaining value must be only allowed keywords
// (currentColor/transparent/inherit/none/0); otherwise a color
// Sub-rule 1: colors. After stripping allowed keywords
// (currentColor/transparent/inherit/none/0), any color
// literal is reported.
if (COLOR_PROPS.test(prop)) {
const withoutVars = stripVars(value);
@@ -1,10 +1,8 @@
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
// A custom property may be DEFINED inside a component's <style> block, but its
// value must not introduce a design primitive: a raw color literal (hex/rgb/
// oklch/named) or an absolute size (px/rem/em). This keeps the design surface
// (colors/sizes) a single source of truth in app.css, while still allowing
// local DERIVED variables built from tokens: var(--...), calc(), unitless
// ratios (--ratio: 1.5) — those are legitimate component-local state.
// A custom property may be DEFINED in a component's <style>, but its value
// must not introduce a design primitive (raw color, absolute size): the
// design surface stays in app.css, while local DERIVED variables (var(),
// calc(), unitless ratios) remain legitimate.
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
@@ -57,7 +55,7 @@ export default {
const primitive = stripVars(value);
// A color literal in the definition → primitive.
// A color literal in the definition is a primitive.
const colorHit = primitive.match(COLOR_LITERAL);
if (colorHit) {
report(decl, "tokenPrimitive", {
@@ -67,7 +65,7 @@ export default {
return;
}
// An absolute size (px/rem/em) in the definition → primitive.
// An absolute size (px/rem/em) in the definition is a primitive.
const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN);
if (sizeHit) {
report(decl, "tokenPrimitive", {
@@ -10,7 +10,7 @@ import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// A custom property DEFINITION: "--x:" followed by a colon.
const DEFINED_RE = /--[\w-]+(?=\s*:)/g;
// A var() REFERENCE (primary argument only — fallbacks are optional overrides).
// A var() REFERENCE (primary argument only; fallbacks are optional overrides).
const VAR_REF_RE = /var\(\s*(--[\w-]+)/g;
const definedCache = new Map();
+9 -33
View File
@@ -1,30 +1,7 @@
/**
* Cross-locale dictionary linter (i18n/dict-consistency).
*
* Each locale dict in `lib/i18n/` is checked against ALL the others (not just
* against the base locale). The set of locales comes from `LOCALES` in the
* sibling `dict.ts`, so a new locale is picked up automatically — no rule
* changes needed. The rule only ever warns (a missing translation must not
* break the build) — see docs/plan-preview-i18n.md §Фаза 8.
*
* The diagnostic lives where the fix lives — each file reports its OWN gaps:
* - missing keys: the union of all keys is collected from the on-disk dicts;
* whatever the current file lacks is reported here (a missing whole
* section is reported once, not per descendant key);
* - empty values: `""` or whitespace-only values;
* - placeholders: for a shared key the `{name}` set is compared against the
* canonical set (the one shared by most locales, ties broken by BASE_LOCALE
* then LOCALES order) — catches a variable lost in translation exactly once,
* even when only one locale deviates.
*
* The rule self-filters: files whose basename is not one of LOCALES are
* ignored, so it can be attached to the whole `lib/i18n/` directory.
*
* Options (object, all optional):
* - allowPaths: dot-path keys excluded from every check.
* - baseLocaleFallback: patterns whose missing keys are allowed in BASE_LOCALE.
* - ignoreMissingPatterns: patterns temporarily excluded from missing-key
* checks.
* Cross-locale dictionary linter: each dict in lib/i18n/ is checked against
* ALL others (key parity, empty values, placeholders), warn-only.
* Full semantics and options: web/eslint-plugins/README.md.
*/
import fs from "node:fs";
import path from "node:path";
@@ -54,7 +31,7 @@ function unwrap(node) {
}
/**
* The exported dict `export const <name>: Dict = {...}` — the declarator id
* The exported dict `export const <name>: Dict = {...}`: the declarator id
* (for reporting) and the object literal, or null.
*/
function findDictExport(ast) {
@@ -109,7 +86,7 @@ function findLocalesList(ast) {
/**
* String literal node. The parser emits `Literal` in some ESTree versions and
* `StringLiteral` in others — accept both.
* `StringLiteral` in others; accept both.
*/
function isStringLiteral(node) {
if (!node) return false;
@@ -148,10 +125,9 @@ function propKey(prop) {
}
/**
* Flatten an exported dict object into `path -> { node, kind, value }`.
* Computed properties are skipped; intermediate objects are recorded too, so a
* missing whole section is reported once (children of a missing path are
* skipped when reporting).
* Flatten an exported dict into `path -> { node, kind, value }`. Computed
* properties are skipped; intermediate objects are recorded too, so a
* missing whole section is reported once.
*/
function buildKeyMap(objectNode) {
const map = new Map();
@@ -226,7 +202,7 @@ function readDir(dir) {
for (const locale of locales) {
const file = path.join(dir, `${locale}.ts`);
if (!fs.existsSync(file)) {
// Declared in LOCALES but not yet translated — treated as an empty
// Declared in LOCALES but not yet translated; treated as an empty
// dict so every key it lacks reports `missing key ... in <locale>`.
dicts.push({ locale, map: new Map() });
continue;
+3 -7
View File
@@ -1,11 +1,7 @@
/**
* Local ESLint plugin "i18n".
*
* Cross-locale dictionary hygiene (i18n/dict-consistency): every locale dict in
* `lib/i18n/` is compared against all the others — key parity, empty values,
* `{placeholder}` parity — warn-only by design (a missing translation must not
* break the build). The set of locales comes from `LOCALES` in `dict.ts`, so
* new locales are picked up automatically.
* Local ESLint plugin "i18n": cross-locale dictionary hygiene
* (dict-consistency) and no hardcoded user text in templates. Warn-only by
* design. Rule details: web/eslint-plugins/README.md.
*/
import dictConsistency from "./dict-consistency.js";
import noHardcodedUserText from "./no-hardcoded-user-text.js";
@@ -1,19 +1,7 @@
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES.
//
// Every user-visible string must come from the dictionaries via t(); a literal in
// the template silently ships English to the RU locale. Warn-only by design
// (report-only pilot), so the baseline stays green while the rule is calibrated.
//
// What is NOT reported (the pilot's exclusion list, from the plan):
// - brand and version strings ("easy-png-tools", "v0.1.0");
// - formats, file names and units ("PNG", "result.png", "512×512", "px", "%");
// - X/Y axis markers and other one-letter technical markers;
// - registry source strings (ALL CAPS identifiers such as "BACKGROUND");
// - /kit, which is a component gallery with deliberately hardcoded labels.
//
// Detection surface: SvelteText nodes (including the text parts of mixed content)
// and string-literal values of user-facing attributes. Values written as
// expressions ({...}) are not inspected — the value is computed elsewhere.
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES. Every user-visible
// string must come from the dictionaries via t(); a literal silently ships
// English to the RU locale. Warn-only report-only pilot. Exclusion list
// (brand, units, registry strings, /kit): web/eslint-plugins/README.md.
import { USER_TEXT_ATTRS } from "./lists.js";
+3 -6
View File
@@ -1,10 +1,7 @@
/**
* Local ESLint plugin "design-tokens".
*
* Goal: a single source of truth for design. In Svelte components, hardcoded
* colors and sizes are banned; everything must come from CSS variables (tokens
* in app.css). Rules inspect the postcss AST of Svelte <style> blocks
* exposed by svelte-eslint-parser.
* Local ESLint plugin "design-tokens": hardcoded colors/sizes are banned in
* Svelte <style> blocks, everything comes from CSS tokens in app.css.
* Rule details: web/eslint-plugins/README.md.
*/
import noCategoryMismatch from "./design-tokens/no-category-mismatch.js";
import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js";