From 58db751089482c9dd2e3f489fb2f062a1bff0f86 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 6 May 2026 16:40:21 +0800 Subject: [PATCH] ci(lint): enable lint in CI + fix existing lint debt (#2129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was running build + typecheck + test, but never lint. The i18n guardrail (eslint-plugin-i18next on packages/views/**/*.tsx) was configured but not enforced, so PRs kept landing user-facing English strings (chat session delete, project resources, mermaid fallback, invitations batch page). Changes: - .github/workflows/ci.yml: add `lint` to the turbo command - packages/eslint-config/react.js: split React rules (JSX-only) from react-hooks rules (apply to .ts too) — hooks live in .ts modules like use-agent-presence.ts, and inline-disable comments need the rule registered to resolve - Translate the 10 lint errors that surfaced: - editor/readonly-content.tsx mermaid render-error + rendering - issues/issue-detail.tsx Archive tooltip - invitations/invitations-page.tsx full page (new invite.batch.*) - invitations-page.test.tsx wrap with I18nProvider so getByRole queries match translated button labels - core/auth/utils.ts intentional control-char regex: add eslint-disable Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 4 +- packages/core/auth/utils.ts | 1 + packages/eslint-config/react.js | 17 +++++--- packages/views/editor/readonly-content.tsx | 5 ++- .../invitations/invitations-page.test.tsx | 13 ++++-- .../views/invitations/invitations-page.tsx | 41 +++++++++++-------- .../views/issues/components/issue-detail.tsx | 2 +- packages/views/locales/en/editor.json | 4 ++ packages/views/locales/en/invite.json | 17 ++++++++ packages/views/locales/en/issues.json | 1 + packages/views/locales/zh-Hans/editor.json | 4 ++ packages/views/locales/zh-Hans/invite.json | 16 ++++++++ packages/views/locales/zh-Hans/issues.json | 1 + 13 files changed, 96 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9070388f..59dcb5f42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,8 @@ jobs: - name: Install dependencies run: pnpm install - - name: Build, type check, and test - run: pnpm exec turbo build typecheck test --filter='!@multica/docs' + - name: Build, type check, lint, and test + run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs' backend: runs-on: ubuntu-latest diff --git a/packages/core/auth/utils.ts b/packages/core/auth/utils.ts index 97ebe42c7..8c1010e73 100644 --- a/packages/core/auth/utils.ts +++ b/packages/core/auth/utils.ts @@ -15,6 +15,7 @@ export function sanitizeNextUrl(raw: string | null): string | null { if (!raw) return null; if (!raw.startsWith("/") || raw.startsWith("//")) return null; + // eslint-disable-next-line no-control-regex -- intentional: rejecting control chars is the whole point if (/[\x00-\x1f\\]/.test(raw)) return null; return raw; } diff --git a/packages/eslint-config/react.js b/packages/eslint-config/react.js index 5eafc3c53..78d58189c 100644 --- a/packages/eslint-config/react.js +++ b/packages/eslint-config/react.js @@ -5,16 +5,13 @@ import reactHooksPlugin from "eslint-plugin-react-hooks"; /** @type {import("eslint").Linter.Config[]} */ export default [ ...baseConfig, + // React rules (JSX only) { files: ["**/*.{jsx,tsx}"], - plugins: { - react: reactPlugin, - "react-hooks": reactHooksPlugin, - }, + plugins: { react: reactPlugin }, rules: { ...reactPlugin.configs.recommended.rules, ...reactPlugin.configs["jsx-runtime"].rules, - ...reactHooksPlugin.configs["recommended-latest"].rules, "react/prop-types": "off", "react/no-unknown-property": "off", }, @@ -22,4 +19,14 @@ export default [ react: { version: "detect" }, }, }, + // React Hooks rules apply to .ts files too — hooks (useEffect, useCallback, + // useMemo) can live in plain .ts modules and we want exhaustive-deps to + // run + inline disable comments to resolve. + { + files: ["**/*.{ts,tsx,js,jsx}"], + plugins: { "react-hooks": reactHooksPlugin }, + rules: { + ...reactHooksPlugin.configs["recommended-latest"].rules, + }, + }, ]; diff --git a/packages/views/editor/readonly-content.tsx b/packages/views/editor/readonly-content.tsx index 41bf77aba..788477dbe 100644 --- a/packages/views/editor/readonly-content.tsx +++ b/packages/views/editor/readonly-content.tsx @@ -330,6 +330,7 @@ function MermaidLightbox({ } function MermaidDiagram({ chart }: { chart: string }) { + const { t } = useT("editor"); const reactId = useId(); const containerRef = useRef(null); const diagramId = useMemo( @@ -386,7 +387,7 @@ function MermaidDiagram({ chart }: { chart: string }) { if (error) { return (
-

Unable to render Mermaid diagram.

+

{t(($) => $.mermaid.render_error)}

           {chart}
         
@@ -426,7 +427,7 @@ function MermaidDiagram({ chart }: { chart: string }) { )} ) : ( -
Rendering diagram…
+
{t(($) => $.mermaid.rendering)}
)}
); diff --git a/packages/views/invitations/invitations-page.test.tsx b/packages/views/invitations/invitations-page.test.tsx index acdd30d43..18a6dd942 100644 --- a/packages/views/invitations/invitations-page.test.tsx +++ b/packages/views/invitations/invitations-page.test.tsx @@ -56,13 +56,20 @@ vi.mock("@multica/core/api", () => ({ }, })); +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../locales/en/common.json"; +import enInvite from "../locales/en/invite.json"; import { InvitationsPage } from "./invitations-page"; +const TEST_RESOURCES = { en: { common: enCommon, invite: enInvite } }; + function renderWithClient(client: QueryClient = new QueryClient()) { return render( - - - , + + + + + , ); } diff --git a/packages/views/invitations/invitations-page.tsx b/packages/views/invitations/invitations-page.tsx index f6a08ddac..329633996 100644 --- a/packages/views/invitations/invitations-page.tsx +++ b/packages/views/invitations/invitations-page.tsx @@ -14,6 +14,7 @@ import type { Invitation } from "@multica/core/types"; import { useNavigation } from "../navigation"; import { useLogout } from "../auth"; import { DragStrip } from "../platform"; +import { useT } from "../i18n"; import { Button } from "@multica/ui/components/ui/button"; import { Card, CardContent } from "@multica/ui/components/ui/card"; import { Checkbox } from "@multica/ui/components/ui/checkbox"; @@ -39,6 +40,7 @@ import { LogOut, Mail, Users } from "lucide-react"; * action. */ export function InvitationsPage() { + const { t } = useT("invite"); const { push } = useNavigation(); const qc = useQueryClient(); const [selected, setSelected] = useState>(new Set()); @@ -112,7 +114,7 @@ export function InvitationsPage() { setError( e instanceof Error ? e.message - : "Failed to process invitations. Please try again.", + : t(($) => $.batch.error_generic), ); // Partial success: any accepts that landed before the failure ALREADY // set onboarded_at on the backend (the AcceptInvitation transaction @@ -157,12 +159,12 @@ export function InvitationsPage() {
-

No pending invitations

+

{t(($) => $.batch.empty_title)}

- Continue to set up your own workspace. + {t(($) => $.batch.empty_hint)}

@@ -172,10 +174,8 @@ export function InvitationsPage() { const submitLabel = selected.size === 0 - ? "Skip and set up my own workspace" - : selected.size === 1 - ? "Join 1 workspace" - : `Join ${selected.size} workspaces`; + ? t(($) => $.batch.submit_skip) + : t(($) => $.batch.submit_join, { count: selected.size }); return ( @@ -187,11 +187,10 @@ export function InvitationsPage() {

- You've been invited + {t(($) => $.batch.title)}

- Pick the workspaces you want to join. You can always handle the - rest later from the sidebar. + {t(($) => $.batch.subtitle)}

@@ -212,7 +211,7 @@ export function InvitationsPage() { onClick={handleSubmit} disabled={submitting} > - {submitting ? "Joining..." : submitLabel} + {submitting ? t(($) => $.batch.joining) : submitLabel} {error && ( @@ -233,7 +232,15 @@ function InvitationRow({ checked: boolean; onToggle: () => void; }) { - const inviter = invitation.inviter_name || invitation.inviter_email || "Someone"; + const { t } = useT("invite"); + const inviter = + invitation.inviter_name || + invitation.inviter_email || + t(($) => $.batch.row_inviter_fallback); + const roleLine = + invitation.role === "admin" + ? t(($) => $.batch.row_invited_admin, { inviter }) + : t(($) => $.batch.row_invited_member, { inviter }); return (
  • @@ -259,6 +265,7 @@ function InvitationRow({ } function InvitationsShell({ children }: { children: ReactNode }) { + const { t } = useT("invite"); const logout = useLogout(); return (
    @@ -270,7 +277,7 @@ function InvitationsShell({ children }: { children: ReactNode }) { onClick={logout} > - Log out + {t(($) => $.batch.log_out)}
    {children} diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 733f06b45..7eeae0473 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -666,7 +666,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr } /> - Archive + {t(($) => $.detail.archive_tooltip)} )} diff --git a/packages/views/locales/en/editor.json b/packages/views/locales/en/editor.json index e276eac49..72b9b052f 100644 --- a/packages/views/locales/en/editor.json +++ b/packages/views/locales/en/editor.json @@ -54,5 +54,9 @@ }, "title_editor": { "title_aria_label": "Title" + }, + "mermaid": { + "render_error": "Unable to render Mermaid diagram.", + "rendering": "Rendering diagram…" } } diff --git a/packages/views/locales/en/invite.json b/packages/views/locales/en/invite.json index 72e426fd9..83b9859fd 100644 --- a/packages/views/locales/en/invite.json +++ b/packages/views/locales/en/invite.json @@ -33,5 +33,22 @@ "errors": { "accept_failed": "Failed to accept invitation", "decline_failed": "Failed to decline invitation" + }, + "batch": { + "log_out": "Log out", + "empty_title": "No pending invitations", + "empty_hint": "Continue to set up your own workspace.", + "empty_continue": "Continue to setup", + "title": "You've been invited", + "subtitle": "Pick the workspaces you want to join. You can always handle the rest later from the sidebar.", + "submit_skip": "Skip and set up my own workspace", + "submit_join_one": "Join 1 workspace", + "submit_join_other": "Join {{count}} workspaces", + "joining": "Joining...", + "error_generic": "Failed to process invitations. Please try again.", + "row_workspace_fallback": "Workspace", + "row_inviter_fallback": "Someone", + "row_invited_admin": "{{inviter}} invited you as an admin", + "row_invited_member": "{{inviter}} invited you as a member" } } diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index d7260635f..ee8278698 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -122,6 +122,7 @@ "members_group": "Members", "agents_group": "Agents", "mark_done_tooltip": "Mark as done", + "archive_tooltip": "Archive", "pin_tooltip": "Pin to sidebar", "unpin_tooltip": "Unpin from sidebar", "sidebar_tooltip": "Toggle sidebar", diff --git a/packages/views/locales/zh-Hans/editor.json b/packages/views/locales/zh-Hans/editor.json index c258cddf4..5b5ff5150 100644 --- a/packages/views/locales/zh-Hans/editor.json +++ b/packages/views/locales/zh-Hans/editor.json @@ -54,5 +54,9 @@ }, "title_editor": { "title_aria_label": "标题" + }, + "mermaid": { + "render_error": "无法渲染 Mermaid 图。", + "rendering": "渲染中…" } } diff --git a/packages/views/locales/zh-Hans/invite.json b/packages/views/locales/zh-Hans/invite.json index fe9292715..8a7123374 100644 --- a/packages/views/locales/zh-Hans/invite.json +++ b/packages/views/locales/zh-Hans/invite.json @@ -33,5 +33,21 @@ "errors": { "accept_failed": "接受邀请失败", "decline_failed": "拒绝邀请失败" + }, + "batch": { + "log_out": "退出登录", + "empty_title": "没有待处理的邀请", + "empty_hint": "继续创建你自己的工作区。", + "empty_continue": "继续创建", + "title": "你被邀请加入工作区", + "subtitle": "选择你想加入的工作区。其余的可以之后在侧边栏处理。", + "submit_skip": "跳过,创建我自己的工作区", + "submit_join_other": "加入 {{count}} 个工作区", + "joining": "加入中...", + "error_generic": "处理邀请失败,请重试。", + "row_workspace_fallback": "工作区", + "row_inviter_fallback": "某人", + "row_invited_admin": "{{inviter}} 邀请你以管理员身份加入", + "row_invited_member": "{{inviter}} 邀请你以成员身份加入" } } diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index eba418168..b0a178498 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -121,6 +121,7 @@ "members_group": "成员", "agents_group": "智能体", "mark_done_tooltip": "标记为已完成", + "archive_tooltip": "归档", "pin_tooltip": "固定到侧边栏", "unpin_tooltip": "从侧边栏取消固定", "sidebar_tooltip": "切换侧边栏",