chore: update lint rules, add tests

This commit is contained in:
2026-09-11 06:26:42 +05:00
parent 75f6891e7c
commit 098897cddd
18 changed files with 446 additions and 21 deletions
@@ -0,0 +1,11 @@
:root {
--color-fg: #101010;
--space-1: calc(var(--size-1) / 2);
--z-nav: 100;
--duration-fast: 120ms;
--text-md: 0.875rem;
}
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1,2 @@
// Fixture stub for lint-rule tests: content is ignored (the test supplies the
// source code), this file only has to EXIST for import resolution.
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1,199 @@
// Tests for the design-tokens plugin rules (web/eslint-plugins/design-tokens).
//
// Strategy:
// - no-hardcoded-in-svelte, no-category-mismatch, no-token-definition-in-svelte
// are pure AST checks -> standard RuleTester with string cases.
// - no-undefined-in-svelte reads the token dictionary from <cwd>/src/app.css,
// so it runs through the Linter API with cwd pinned to the fixtures dir
// (see __tests__/helpers.ts). The fixture dictionary has exactly five
// tokens; anything else must be reported even if it exists in the REAL
// web/src/app.css.
import { RuleTester, Linter } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
import noHardcoded from "../design-tokens/no-hardcoded-in-svelte.js";
import noCategoryMismatch from "../design-tokens/no-category-mismatch.js";
import noTokenDefinition from "../design-tokens/no-token-definition-in-svelte.js";
import noUndefined from "../design-tokens/no-undefined-in-svelte.js";
import { verifyInFixtures, type FlatConfig } from "./helpers.js";
const parserOptions = {
parser: tseslint.parser,
};
// Vitest doesn't expose describe/it as globals (no `globals: true` in the
// config), so RuleTester would fall back to its synchronous default handler.
// Register the real ones so each case becomes a proper vitest test.
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions,
},
});
/** Wrap CSS from a <style> block into a minimal Svelte component. */
const component = (style: string): string =>
`<div class="box">x</div>\n\n<style>\n${style}\n</style>\n`;
const frame = (style: string) => ({
code: component(style),
filename: "Component.svelte",
});
describe("design-tokens/no-hardcoded-in-svelte", () => {
ruleTester.run("no-hardcoded-in-svelte", noHardcoded, {
valid: [
frame(".box { color: var(--color-fg); }"),
frame(".box { padding: var(--space-3); }"),
frame(".box { background-color: transparent; }"),
frame(".box { width: 100%; }"),
frame(".box { line-height: 1.5; }"),
frame(".box { transition: var(--duration-fast); }"),
frame(".box { z-index: var(--z-nav); }"),
frame(".box { font-size: var(--text-md); }"),
frame("<div>no style block at all</div>"),
],
invalid: [
{
...frame(".box { color: #ff0000; }"),
errors: [{ messageId: "hardcodedColor" }],
},
{
...frame(".box { padding: 16px; }"),
errors: [{ messageId: "hardcodedSize" }],
},
{
...frame(".box { transition: 200ms; }"),
errors: [{ messageId: "hardcodedDuration" }],
},
{
...frame(".box { z-index: 100; }"),
errors: [{ messageId: "hardcodedZIndex" }],
},
{
...frame(
"@media (max-width: 640px) { .box { width: var(--text-md); } }",
),
errors: [{ messageId: "hardcodedBreakpoint" }],
},
{
...frame(
"@media (min-width: var(--bp-m)) { .box { width: var(--text-md); } }",
),
errors: [{ messageId: "varInMedia" }],
},
{
...frame(
".box { color: color-mix(in srgb, var(--color-a), var(--color-b)); }",
),
errors: [{ messageId: "colorMix" }],
},
],
});
});
describe("design-tokens/no-category-mismatch", () => {
ruleTester.run("no-category-mismatch", noCategoryMismatch, {
valid: [
frame(".box { padding: var(--space-1); }"),
frame(".box { color: var(--color-fg); }"),
frame(".box { border: 1px solid var(--color-border); }"),
],
invalid: [
{
...frame(".box { padding: var(--color-fg); }"),
errors: [{ messageId: "categoryMismatch" }],
},
{
...frame(".box { color: var(--space-1); }"),
errors: [{ messageId: "categoryMismatch" }],
},
],
});
});
describe("design-tokens/no-token-definition-in-svelte", () => {
ruleTester.run("no-token-definition-in-svelte", noTokenDefinition, {
valid: [
frame(".box { --local: var(--color-fg); }"),
frame(".box { --local: calc(var(--space-1) * 2); }"),
frame(".box { --ratio: 1.5; }"),
],
invalid: [
{
...frame(".box { --local: #ff0000; }"),
errors: [{ messageId: "tokenPrimitive" }],
},
{
...frame(".box { --local: 12px; }"),
errors: [{ messageId: "tokenPrimitive" }],
},
{
...frame(".box { --local: oklch(0.5 0.1 240); }"),
errors: [{ messageId: "tokenPrimitive" }],
},
],
});
});
describe("design-tokens/no-undefined-in-svelte", () => {
const undefinedConfig: FlatConfig = [
{
files: ["**/*.svelte"],
plugins: {
"design-tokens": { rules: { "no-undefined-in-svelte": noUndefined } },
},
rules: { "design-tokens/no-undefined-in-svelte": "error" },
languageOptions: {
parser: svelteParser,
parserOptions,
},
},
];
it("accepts tokens defined in the fixture app.css", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { color: var(--color-fg); }"),
"lib/components/Foo.svelte",
);
expect(messages).toEqual([]);
});
it("accepts component-local overrides", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { --local-x: var(--color-fg); color: var(--local-x); }"),
"lib/components/Foo.svelte",
);
expect(messages).toEqual([]);
});
it("flags an unknown token even if it exists in the real web/src/app.css", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { color: var(--color-love); }"),
"lib/components/Foo.svelte",
);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("undefinedToken");
expect(messages[0].message).toContain("--color-love");
});
it("flags unknown tokens referenced inside @media params", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(
"@media (width >= var(--bp-xs)) { .box { width: var(--text-md); } }",
),
"lib/components/Foo.svelte",
);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("undefinedToken");
expect(messages[0].message).toContain("--bp-xs");
});
});
+34
View File
@@ -0,0 +1,34 @@
// Shared harness for testing the custom ESLint rules in eslint-plugins/.
//
// Two strategies, picked per rule in the test files:
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
// - Linter API + fixtures — needed when a rule reads real files. The isolation
// rule resolves imports via fs (targets must exist on disk) and
// no-undefined-in-svelte reads <cwd>/src/app.css for the token dictionary.
// `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both
// work without touching process.cwd() or the real src/ tree.
import { Linter } from "eslint";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const FIXTURES_DIR = path.resolve(HERE, "../__fixtures__");
export const FIXTURES_SRC = path.join(FIXTURES_DIR, "src");
/** Flat-config shape the Linter.verify accepts. */
export type FlatConfig = Parameters<Linter["verify"]>[1];
/**
* Run a rule (or a config) against `code` as if it were the fixture file
* `relFile` under `__fixtures__/src`. Returns the lint messages.
*/
export function verifyInFixtures(
config: FlatConfig,
code: string,
relFile: string,
) {
const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR });
return linter.verify(code, config, path.join(FIXTURES_SRC, relFile));
}
@@ -0,0 +1,102 @@
// Tests for the isolation rule (web/eslint-plugins/isolation/no-mixed-imports.js).
//
// The rule RESOLVES import specifiers to real files (via $lib alias and
// relative ../ paths), so every import target must exist on disk. It therefore
// runs through the Linter API with cwd pinned to the committed fixture tree
// (__fixtures__/src) — see __tests__/helpers.ts.
//
// Side map (mirrors the defaults in no-mixed-imports.js):
// old -> routes/v1/**, lib/v1/**
// new -> routes/** (minus v1), lib/components/**, lib/registry/** + a few
// shared-> everything else: core/, i18n/, theme, ...
import { describe, expect, it } from "vitest";
import noMixedImports from "../isolation/no-mixed-imports.js";
import { verifyInFixtures, type FlatConfig } from "./helpers.js";
const isolationConfig: FlatConfig = [
{
files: ["**/*.{ts,svelte}"],
plugins: { isolation: { rules: { "no-mixed-imports": noMixedImports } } },
rules: { "isolation/no-mixed-imports": "error" },
},
];
const messagesFor = (relFile: string, code: string) =>
verifyInFixtures(isolationConfig, code, relFile);
/** Assert a single "noMixed" violation with the given sides. */
function expectViolation(
relFile: string,
code: string,
side: string,
target: string,
) {
const messages = messagesFor(relFile, code);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("noMixed");
// ESLint interpolates `data` into the message text (no `data` field on the
// returned message), so assert on the rendered message instead.
expect(messages[0].message).toContain(`"${side}" file imports "${target}"`);
}
/** Assert the import is allowed (no violations). */
function expectClean(relFile: string, code: string) {
expect(messagesFor(relFile, code)).toEqual([]);
}
describe("isolation/no-mixed-imports", () => {
it("flags an old file importing a new component", () => {
expectViolation(
"lib/v1/old.ts",
'import { c } from "../components/CheckerCanvas.svelte";',
"old",
"new",
);
});
it("flags a new route importing an old module through the $lib alias", () => {
expectViolation(
"routes/+page.svelte",
'import { o } from "$lib/v1/old";',
"new",
"old",
);
});
it("flags a new component importing old code via a relative path", () => {
expectViolation(
"lib/components/CheckerCanvas.svelte",
'import { o } from "../v1/old.ts";',
"new",
"old",
);
});
it("allows old -> shared (core via relative path)", () => {
expectClean("lib/v1/old.ts", 'import { e } from "../core/errors";');
});
it("allows old -> shared (i18n and theme via $lib)", () => {
expectClean("routes/v1/+layout.svelte", 'import { t } from "$lib/i18n/t";');
expectClean(
"routes/v1/+layout.svelte",
'import { g } from "$lib/theme.svelte";',
);
});
it("allows new -> shared and new -> new", () => {
expectClean("routes/+page.svelte", 'import { t } from "$lib/i18n/t";');
expectClean(
"routes/+page.svelte",
'import { b } from "$lib/components/ui/Button.svelte";',
);
});
it("leaves shared files unrestricted in both directions", () => {
expectClean("lib/i18n/t.ts", 'import { o } from "$lib/v1/old";');
expectClean(
"lib/core/errors.ts",
'import { c } from "$lib/components/CheckerCanvas.svelte";',
);
});
});
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Local ESLint plugin "isolation".
*
* Guarantees full isolation between the old UI branch and the new (preview)
* branch. Unlike no-restricted-imports (which matches the literal import
* Guarantees full isolation between the old (v1, archived) UI branch and the
* new one. Unlike no-restricted-imports (which matches the literal import
* specifier string only), these rules RESOLVE the specifier to a real file
* (supporting both `$lib/...` aliases and relative `./`/`../` paths) and then
* classify both sides by their actual location on disk.
@@ -1,5 +1,5 @@
/**
* Isolation rule: forbid mixing the "old" and the "new" (preview) branches.
* Isolation rule: forbid mixing the "old" (v1) and the "new" branches.
*
* The rule RESOLVES every import specifier to a real file (handles both the
* `$lib/...` alias and relative `./` / `../` paths), classifies the source
@@ -17,13 +17,19 @@
import fs from "node:fs";
import path from "node:path";
const DEFAULT_OLD = ["routes/(old)/**", "lib/old/**"];
// NEW declared as an allowlist: `lib/**` descends into v1/ and the shared dirs
// (core/, i18n/, assets/, theme), so matching by prefix would misclassify them.
// Negations (`!`) carve old paths out of `routes/**`.
const DEFAULT_OLD = ["routes/v1/**", "lib/v1/**"];
const DEFAULT_NEW = [
"routes/preview/**",
"lib/components/kit/**",
"lib/preview/**",
"lib/registry-new/**",
"routes/**",
"!routes/v1/**",
"lib/components/**",
"lib/registry/**",
"lib/catalog.ts",
"lib/categories.ts",
"lib/tool-icons.ts",
"lib/registry-schema.ts",
"lib/registry-schema.test.ts",
];
@@ -92,7 +98,7 @@ export default {
type: "problem",
docs: {
description:
"Forbid imports between the old UI branch and the new (preview) branch.",
"Forbid imports between the old (v1) UI branch and the new branch.",
category: "Best Practices",
},
schema: [