chore(ui): add filename, size, JSX-handler, prefer-const, and antd lint rules (#34341)

* chore(ui): add filename, size, JSX-handler, prefer-const, and antd lint rules

Wires up five error-level ESLint rules on the dashboard, grandfathering every
current offender into eslint-suppressions.json so the gate only bites new code
and ratchets down as files are fixed

- local/filename-pascal-case: new local rule requiring PascalCase .tsx names,
  exempting Next.js reserved files (page, layout, route, ...) and test/spec files
  (239 grandfathered)
- max-lines: 800 lines over src/**, excluding tests, src/data, and generated
  schema.d.ts (20 grandfathered)
- local/no-complex-jsx-arrow: new local rule flagging inline JSX arrow handlers
  with block bodies over two statements; each failure is a small extract-to-named
  -handler refactor (65 grandfathered)
- prefer-const: flipped from off to error (103 grandfathered)
- no-restricted-imports: added antd to the phase-out ban alongside tremor, and
  pointed both messages at shadcn/ui primitives (405 antd import sites grandfathered)

Both new local rules ship with RuleTester coverage

* fix(ui): preserve secondary extensions in filename-pascal-case suggestion

The suggestion text built the rename from only the head segment, so a
multi-dot file like my-component.utils.tsx was told to become
MyComponent.tsx instead of MyComponent.utils.tsx. Rebuild it from the
PascalCased head plus the untouched remaining segments, and add tests
covering multi-dot filenames and the hyphenated Next.js reserved names
(global-error, apple-icon, opengraph-image, twitter-image)
This commit is contained in:
ryan-crabbe-berri 2026-07-22 19:34:35 -07:00 • committed by GitHub
parent 0c2b86e167
commit 67fce87b16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 2766 additions and 147 deletions

File diff suppressed because it is too large Load diff

View file

@ -19,12 +19,13 @@ const eslintConfig = [
"unused-imports/no-unused-imports": "error",
"local/no-large-inline-object-arg": "warn",
"local/no-long-condition-chain": "warn",
"local/no-complex-jsx-arrow": ["error", { maxStatements: 2 }],
"@typescript-eslint/no-explicit-any": "warn",
"no-console": ["warn", { allow: ["warn", "error"] }],
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/ban-ts-comment": "off",
"prefer-const": "off",
"prefer-const": "error",
"no-empty": "off",
"no-prototype-builtins": "off",
"no-useless-catch": "off",
@ -51,13 +52,32 @@ const eslintConfig = [
patterns: [
{
group: ["@tremor/react", "@tremor/react/*"],
message: "@tremor/react is being phased out; build new UI with antd instead of adding tremor imports.",
message:
"@tremor/react is being phased out; build new UI with shadcn/ui primitives instead of adding tremor imports.",
},
{
group: ["antd", "antd/*"],
message:
"antd is being phased out; build new UI with shadcn/ui primitives instead of adding antd imports.",
},
],
},
],
},
},
{
files: ["src/**/*.tsx"],
rules: {
"local/filename-pascal-case": "error",
},
},
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/**/*.test.{ts,tsx}", "src/**/*.spec.{ts,tsx}", "src/data/**"],
rules: {
"max-lines": ["error", { max: 800, skipBlankLines: true, skipComments: true }],
},
},
{
files: ["src/lib/http/**"],
rules: {

View file

@ -0,0 +1,59 @@
import { basename } from "path";
const NEXT_RESERVED = new Set([
"page",
"layout",
"route",
"template",
"default",
"loading",
"error",
"global-error",
"not-found",
"middleware",
"instrumentation",
"sitemap",
"robots",
"manifest",
"icon",
"apple-icon",
"favicon",
"opengraph-image",
"twitter-image",
]);
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
const rule = {
meta: {
type: "suggestion",
docs: {
description: "Require PascalCase filenames for .tsx modules; exempt Next.js reserved files and test/spec files.",
},
schema: [],
messages: {
notPascalCase: "Filename '{{name}}' should be PascalCase (e.g. '{{suggestion}}.tsx').",
},
},
create(context) {
const filename = context.filename;
const stem = basename(filename).replace(/\.tsx$/, "");
const [head, ...rest] = stem.split(".");
if (rest.includes("test") || rest.includes("spec")) return {};
if (NEXT_RESERVED.has(head)) return {};
if (PASCAL_CASE.test(head)) return {};
const pascalHead = head
.split(/[-_]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
const suggestion = [pascalHead, ...rest].join(".");
return {
Program(node) {
context.report({ node, messageId: "notPascalCase", data: { name: `${stem}.tsx`, suggestion } });
},
};
},
};
export default rule;

View file

@ -1,10 +1,14 @@
import noLargeInlineObjectArg from "./no-large-inline-object-arg.mjs";
import noLongConditionChain from "./no-long-condition-chain.mjs";
import noComplexJsxArrow from "./no-complex-jsx-arrow.mjs";
import filenamePascalCase from "./filename-pascal-case.mjs";
const plugin = {
rules: {
"no-large-inline-object-arg": noLargeInlineObjectArg,
"no-long-condition-chain": noLongConditionChain,
"no-complex-jsx-arrow": noComplexJsxArrow,
"filename-pascal-case": filenamePascalCase,
},
};

View file

@ -0,0 +1,41 @@
const DEFAULT_MAX_STATEMENTS = 2;
const isJsxAttributeValue = (node) => {
const parent = node.parent;
if (parent == null) return false;
return parent.type === "JSXExpressionContainer" && parent.parent?.type === "JSXAttribute";
};
const rule = {
meta: {
type: "suggestion",
docs: {
description:
"Disallow arrow functions with block bodies over a few statements passed inline as JSX attributes; extract them into a named handler.",
},
schema: [
{
type: "object",
properties: { maxStatements: { type: "integer", minimum: 1 } },
additionalProperties: false,
},
],
messages: {
tooComplex: "Inline JSX arrow handler has {{count}} statements; extract it into a named function (max {{max}}).",
},
},
create(context) {
const maxStatements = context.options[0]?.maxStatements ?? DEFAULT_MAX_STATEMENTS;
return {
ArrowFunctionExpression(node) {
if (node.body.type !== "BlockStatement") return;
if (!isJsxAttributeValue(node)) return;
const count = node.body.body.length;
if (count <= maxStatements) return;
context.report({ node, messageId: "tooComplex", data: { count, max: maxStatements } });
},
};
},
};
export default rule;

View file

@ -0,0 +1,47 @@
import { RuleTester } from "eslint";
import rule from "../../scripts/eslint-rules/filename-pascal-case.mjs";
const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: "latest", sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } } },
});
ruleTester.run("filename-pascal-case", rule as never, {
valid: [
{ code: "export const x = 1;", filename: "src/components/TeamInfo.tsx" },
{ code: "export const x = 1;", filename: "src/components/Button.tsx" },
{ code: "export const x = 1;", filename: "src/app/teams/page.tsx" },
{ code: "export const x = 1;", filename: "src/app/teams/layout.tsx" },
{ code: "export const x = 1;", filename: "src/app/teams/not-found.tsx" },
{ code: "export const x = 1;", filename: "src/app/global-error.tsx" },
{ code: "export const x = 1;", filename: "src/app/apple-icon.tsx" },
{ code: "export const x = 1;", filename: "src/app/opengraph-image.tsx" },
{ code: "export const x = 1;", filename: "src/app/twitter-image.tsx" },
{ code: "export const x = 1;", filename: "src/components/TeamInfo.test.tsx" },
{ code: "export const x = 1;", filename: "src/components/view_users.test.tsx" },
{ code: "export const x = 1;", filename: "src/components/TeamInfo.spec.tsx" },
],
invalid: [
{
code: "export const x = 1;",
filename: "src/components/user_info_view.tsx",
errors: [{ messageId: "notPascalCase", data: { name: "user_info_view.tsx", suggestion: "UserInfoView" } }],
},
{
code: "export const x = 1;",
filename: "src/components/team-info.tsx",
errors: [{ messageId: "notPascalCase", data: { name: "team-info.tsx", suggestion: "TeamInfo" } }],
},
{
code: "export const x = 1;",
filename: "src/components/teamInfo.tsx",
errors: [{ messageId: "notPascalCase", data: { name: "teamInfo.tsx", suggestion: "TeamInfo" } }],
},
{
code: "export const x = 1;",
filename: "src/components/my-component.utils.tsx",
errors: [
{ messageId: "notPascalCase", data: { name: "my-component.utils.tsx", suggestion: "MyComponent.utils" } },
],
},
],
});

View file

@ -0,0 +1,34 @@
import { RuleTester } from "eslint";
import rule from "../../scripts/eslint-rules/no-complex-jsx-arrow.mjs";
const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: "latest", sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } } },
});
ruleTester.run("no-complex-jsx-arrow", rule as never, {
valid: [
"const x = <button onClick={() => doThing()} />;",
"const x = <button onClick={() => { a(); }} />;",
"const x = <button onClick={() => { a(); b(); }} />;",
"const handler = () => { a(); b(); c(); }; const x = <button onClick={handler} />;",
"const run = () => { a(); b(); c(); };",
"foo(() => { a(); b(); c(); });",
"const x = <List renderItem={(i) => i.name} />;",
{ code: "const x = <button onClick={() => { a(); b(); c(); }} />;", options: [{ maxStatements: 3 }] },
],
invalid: [
{
code: "const x = <button onClick={() => { a(); b(); c(); }} />;",
errors: [{ messageId: "tooComplex", data: { count: 3, max: 2 } }],
},
{
code: "const x = <form onSubmit={() => { a(); b(); c(); d(); }} />;",
errors: [{ messageId: "tooComplex", data: { count: 4, max: 2 } }],
},
{
code: "const x = <button onClick={() => { a(); b(); }} />;",
options: [{ maxStatements: 1 }],
errors: [{ messageId: "tooComplex", data: { count: 2, max: 1 } }],
},
],
});