refactor: enforce frontend view model boundaries
This commit is contained in:
+116
-9
@@ -16,11 +16,13 @@ import storybook from "eslint-plugin-storybook";
|
||||
import testingLibrary from "eslint-plugin-testing-library";
|
||||
import unicorn from "eslint-plugin-unicorn";
|
||||
import assert from "node:assert/strict";
|
||||
import { globSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
|
||||
const eslint = new ESLint();
|
||||
|
||||
for (const [filePath, code, rule] of [
|
||||
[
|
||||
"src/shared/api/problem-details.ts",
|
||||
@@ -110,6 +112,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
||||
rule,
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of [
|
||||
"src/shared/api/generated/schema.d.ts",
|
||||
"packages/ui/dist/index.js",
|
||||
@@ -117,9 +120,11 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
||||
"coverage/index.js",
|
||||
])
|
||||
assert.equal(await eslint.isPathIgnored(file), true, file);
|
||||
|
||||
const story = await eslint.calculateConfigForFile(
|
||||
"src/shared/ui/Button.stories.tsx",
|
||||
);
|
||||
|
||||
assert.equal(story.rules["import-x/no-default-export"][0], 0);
|
||||
assert.ok(story.rules["storybook/default-exports"][0] > 0);
|
||||
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
|
||||
@@ -130,6 +135,7 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
|
||||
const source = "src/shared/api/problem-details.ts";
|
||||
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
|
||||
|
||||
for (const [preset, filePath] of [
|
||||
[tseslint.configs.recommendedTypeChecked, source],
|
||||
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
|
||||
@@ -147,12 +153,25 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
|
||||
[vitest.configs.recommended, unit],
|
||||
[testingLibrary.configs["flat/react"], unit],
|
||||
[jestDom.configs["flat/recommended"], unit],
|
||||
...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]),
|
||||
...storybook.configs["flat/recommended"]
|
||||
.filter(config => config.rules)
|
||||
.map(config => [
|
||||
config,
|
||||
config.name.endsWith("main-rules")
|
||||
? ".storybook/main.ts"
|
||||
: "src/shared/ui/Button.stories.tsx",
|
||||
]),
|
||||
]) {
|
||||
const configs = [preset].flat();
|
||||
const expected = Object.assign({}, ...configs.map(config => config.rules));
|
||||
|
||||
const expected = Object.assign(
|
||||
{},
|
||||
...configs.map(config => config.rules),
|
||||
);
|
||||
|
||||
const config = await eslint.calculateConfigForFile(filePath);
|
||||
const actual = new Map(Object.entries(config.rules));
|
||||
|
||||
for (const [rule, setting] of Object.entries(expected)) {
|
||||
const severity = Array.isArray(setting) ? setting[0] : setting;
|
||||
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
|
||||
@@ -164,10 +183,12 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy
|
||||
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
|
||||
const eslint = new ESLint({ fix: true });
|
||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||
|
||||
const [fixed] = await eslint.lintText(
|
||||
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
|
||||
{ filePath },
|
||||
);
|
||||
|
||||
assert.equal(fixed.fatalErrorCount, 0);
|
||||
assert.equal(fixed.errorCount, 0);
|
||||
assert.ok(fixed.output);
|
||||
@@ -178,10 +199,12 @@ test("ESLint formatting is idempotent and preserves TSX generic syntax", async (
|
||||
|
||||
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
|
||||
const eslint = new ESLint({ fix: true });
|
||||
|
||||
const [fixed] = await eslint.lintText(
|
||||
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
|
||||
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
|
||||
);
|
||||
|
||||
assert.equal(fixed.errorCount, 0);
|
||||
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
|
||||
});
|
||||
@@ -190,11 +213,25 @@ test("ESLint formatting preserves spaces around JSX text and interpolations", as
|
||||
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
|
||||
const eslint = new ESLint();
|
||||
const filePath = "src/shared/api/problem-details.ts";
|
||||
for (const code of ["export function check(code: string) { return code === \"one-off-code\"; }", "export const result = `MOEX_UNAVAILABLE`;", "export const result = { code: \"one-off-code\" };", "export const result = { \"errorCode\": \"one-off-code\" };", "export const result = { code: `dynamic-${Date.now()}` };", "export const result = \"MOEX_UNAVAILABLE\";", "export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];", "export const result = { OPERATION_TYPE_COUPON: \"income\" };"]) {
|
||||
|
||||
for (const code of [
|
||||
"export function check(code: string) { return code === \"one-off-code\"; }",
|
||||
"export const result = `MOEX_UNAVAILABLE`;",
|
||||
"export const result = { code: \"one-off-code\" };",
|
||||
"export const result = { \"errorCode\": \"one-off-code\" };",
|
||||
"export const result = { code: `dynamic-${Date.now()}` };",
|
||||
"export const result = \"MOEX_UNAVAILABLE\";",
|
||||
"export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];",
|
||||
"export const result = { OPERATION_TYPE_COUPON: \"income\" };",
|
||||
]) {
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
assert.equal(result.fatalErrorCount, 0, code);
|
||||
assert.ok(result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"), code);
|
||||
assert.ok(
|
||||
result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"),
|
||||
code,
|
||||
);
|
||||
}
|
||||
|
||||
for (const code of [
|
||||
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
|
||||
"export const select = { code: true, errorCode: true };",
|
||||
@@ -202,10 +239,80 @@ test("machine codes require enum members without banning ordinary text or Prisma
|
||||
]) {
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
assert.equal(result.fatalErrorCount, 0, code);
|
||||
assert.ok(result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), code);
|
||||
assert.ok(
|
||||
result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
|
||||
code,
|
||||
);
|
||||
}
|
||||
const [fixture] = await eslint.lintText("export const result = { code: \"MOEX_UNAVAILABLE\" };", { filePath: "src/shared/api/client.test.ts" });
|
||||
assert.ok(fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"));
|
||||
const [comparison] = await eslint.lintText("enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }", { filePath });
|
||||
assert.ok(comparison.messages.some(({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison"));
|
||||
|
||||
const [fixture] = await eslint.lintText(
|
||||
"export const result = { code: \"MOEX_UNAVAILABLE\" };",
|
||||
{ filePath: "src/shared/api/client.test.ts" },
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"),
|
||||
);
|
||||
|
||||
const [comparison] = await eslint.lintText(
|
||||
"enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }",
|
||||
{ filePath },
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
comparison.messages.some(
|
||||
({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("readability enforces syntax without banning VM and schema patterns", async () => {
|
||||
const eslint = new ESLint();
|
||||
const component = "src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx";
|
||||
const model = "src/entities/income/api/useIncome.ts";
|
||||
|
||||
const invalid = [
|
||||
[component, "const Example = () => <svg><path d=\"M0 0\" /></svg>;", "frontend/readability"],
|
||||
[component, "const Example = () => <button onClick={() => alert(\"go\")} />;", "no-restricted-syntax"],
|
||||
[component, "const Example = () => <button title={true ? \"yes\" : \"no\"} />;", "no-restricted-syntax"],
|
||||
[component, "const Example = () => { const helper = () => 1; return <span>{helper()}</span>; };", "frontend/readability"],
|
||||
[component, "import { z } from \"zod\"; const schema = z.string();", "frontend/readability"],
|
||||
[component, "const schema = otherSchema.superRefine(() => {});", "frontend/readability"],
|
||||
[model, "function useExample(accountId: string | undefined) { return accountId; }", "frontend/readability"],
|
||||
[model, "type Example = { state: \"one\" } | { state: \"two\" };", "frontend/readability"],
|
||||
[component, "const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", "frontend/jsx-spacing"],
|
||||
[model, `const excessivelyLongDeclarationName = ${"1234567890".repeat(9)};`, "@stylistic/max-len"],
|
||||
];
|
||||
|
||||
for (const [filePath, code, rule] of invalid) {
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
assert.equal(result.fatalErrorCount, 0, code);
|
||||
assert.ok(result.messages.some(message => message.ruleId === rule), `${rule}: ${code}`);
|
||||
}
|
||||
|
||||
for (const [filePath, code] of [
|
||||
["src/shared/lib/calendar.schema.ts", "import { z } from \"zod\"; const calendar = z.iso.date();"],
|
||||
["packages/icons/src/svg/AddIcon.tsx", "const Icon = () => <svg><path d=\"M0 0\" /></svg>;"],
|
||||
[model, "function useExample(accountId?: string) { const onClick = () => accountId; return { onClick }; }"],
|
||||
[model, "function example(value: string | undefined, required: string) { return value ?? required; }"],
|
||||
[model, "type Example =\n | { state: \"one\" }\n | { state: \"two\" };"],
|
||||
[component, "const Example = (props: { onClick: () => void }) => <button onClick={props.onClick} />;"],
|
||||
]) {
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
assert.equal(result.fatalErrorCount, 0, code);
|
||||
assert.ok(result.messages.every(message => !["frontend/readability", "no-restricted-syntax"].includes(message.ruleId)), code);
|
||||
}
|
||||
|
||||
assert.deepEqual(globSync("src/**/*.svg"), []);
|
||||
});
|
||||
|
||||
test("JSX spacing fixes preserve text interpolation and are stable", async () => {
|
||||
const eslint = new ESLint({ fix: true });
|
||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||
const [fixed] = await eslint.lintText("const Example = () => <div>\n<span />\n<span />\n</div>; export { Example };", { filePath });
|
||||
const output = fixed.output;
|
||||
assert.ok(output);
|
||||
const [again] = await eslint.lintText(output, { filePath });
|
||||
assert.equal(again.output, undefined);
|
||||
assert.equal(again.errorCount, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user