diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7162fd64..6c1be41d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,24 +34,28 @@ jobs: id: filter uses: dorny/paths-filter@v3 with: - # apps/docs is excluded from the frontend turbo run, so a - # docs-only change does not need this job. apps/mobile has its - # own mobile-verify workflow. Everything else the frontend job - # touches is listed here; bias toward over-matching since a - # missed path silently skips validation. + # apps/docs is excluded from the frontend turbo run, but knip does + # analyze it, so a docs-only change still needs this job. + # apps/mobile has its own mobile-verify workflow. Everything else + # the frontend job touches is listed here; bias toward + # over-matching since a missed path silently skips validation. filters: | frontend: - 'apps/web/**' - 'apps/desktop/**' + - 'apps/docs/**' - 'packages/**' - 'package.json' - '.npmrc' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - 'turbo.json' + - 'knip.jsonc' - '.github/workflows/ci.yml' - 'scripts/generate-reserved-slugs.mjs' - 'server/internal/handler/reserved_slugs.json' + - 'scripts/check-ui-wildcard-exports.mjs' + - 'scripts/check-ui-wildcard-exports.test.sh' - 'scripts/selfhost-config.test.sh' - 'scripts/drop-database.sh' - 'scripts/remove-worktree.sh' @@ -213,6 +217,35 @@ jobs: # exclude @multica/mobile. run: pnpm exec turbo build typecheck lint --filter='!@multica/docs' --filter='!@multica/mobile' + # Blocking from the start: it reports nothing today, and it covers the + # one regression knip structurally cannot see. packages/ui exports four + # wildcards, which knip expands into entry-point globs over every file + # in those directories — so a newly stranded component there always + # reads as "used". See the script header for the options ruled out. + - name: Check packages/ui wildcard exports + if: ${{ needs.changes.outputs.frontend == 'true' }} + run: | + bash scripts/check-ui-wildcard-exports.test.sh + node scripts/check-ui-wildcard-exports.mjs + + # WARN PHASE — reports, never blocks. `shamefully-hoist=true` in .npmrc + # hides both halves of the drift this catches: an undeclared import still + # resolves, and an obsolete declaration still installs. Neither shows up + # in build, typecheck, or lint. + # + # `continue-on-error` alone, with no `|| true`: the real exit code has to + # reach Actions for the step to show as a warning rather than a silent + # pass, and it is what makes the promotion below a one-line change. + # + # TO PROMOTE TO BLOCKING: once this step has run clean for a full cycle + # on main, drop `continue-on-error`. The findings it reports today are + # pre-existing dead code left out of the cleanup that introduced it + # (MUL-6353) — clear those first, or the flip turns them into a red main. + - name: Check for dead code and phantom dependencies (knip) + if: ${{ needs.changes.outputs.frontend == 'true' }} + continue-on-error: true + run: pnpm knip + frontend-test: needs: changes runs-on: ubuntu-latest diff --git a/apps/web/package.json b/apps/web/package.json index 3be1a8fbb..28f553dac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,64 +13,20 @@ "postinstall": "fumadocs-mdx" }, "dependencies": { - "@base-ui/react": "^1.3.0", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@emoji-mart/data": "^1.2.1", - "@floating-ui/dom": "^1.7.6", "@multica/core": "workspace:*", "@multica/ui": "workspace:*", "@multica/views": "workspace:*", "@tanstack/react-query": "catalog:", - "@tanstack/react-query-devtools": "^5.96.2", - "@tiptap/extension-code-block-lowlight": "3.27.1", - "@tiptap/extension-image": "3.27.1", - "@tiptap/extension-link": "3.27.1", - "@tiptap/extension-mention": "3.27.1", - "@tiptap/extension-placeholder": "3.27.1", - "@tiptap/extension-table": "3.27.1", - "@tiptap/extension-table-cell": "3.27.1", - "@tiptap/extension-table-header": "3.27.1", - "@tiptap/extension-table-row": "3.27.1", - "@tiptap/extension-typography": "3.27.1", - "@tiptap/markdown": "3.27.1", - "@tiptap/pm": "3.27.1", - "@tiptap/react": "3.27.1", - "@tiptap/starter-kit": "3.27.1", - "@tiptap/suggestion": "3.27.1", - "@types/linkify-it": "^5.0.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "date-fns": "^4.1.0", "dotenv": "^17.4.1", - "embla-carousel-react": "^8.6.0", - "emoji-mart": "^5.6.0", "fumadocs-core": "^15.5.2", "fumadocs-mdx": "^12.0.3", - "input-otp": "^1.4.2", - "linkify-it": "^5.0.0", - "lowlight": "^3.3.0", "lucide-react": "catalog:", "next": "^16.2.5", - "next-themes": "^0.4.6", "react": "catalog:", - "react-day-picker": "^9.14.0", "react-dom": "catalog:", - "react-markdown": "^10.1.0", - "react-resizable-panels": "^4.7.5", - "recharts": "3.8.0", - "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1", "shadcn": "^4.1.0", - "shiki": "^3.21.0", - "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", - "vaul": "^1.1.2", - "zod": "catalog:", - "zustand": "catalog:" + "zod": "catalog:" }, "devDependencies": { "@multica/eslint-config": "workspace:*", diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 000000000..a19014ef1 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,79 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + + // Guards the invariant in CLAUDE.md "Package Boundaries": every workspace + // declares the external packages it actually imports, and nothing else. + // `.npmrc` sets `shamefully-hoist=true`, so an undeclared import still + // resolves at runtime and an obsolete declaration still installs — neither + // failure is visible without a tool that reads the import graph. + // + // Scoped to `files`, `dependencies`, and `unlisted` on purpose. `exports` is + // the noisy one here: packages/ui and packages/views are consumed through + // per-file `exports` maps, so a named export used by exactly one app still + // reads as "unused" from inside its own workspace. + "include": ["files", "dependencies", "unlisted"], + + // Go, and a workspace with its own Expo toolchain, lint pipeline, and React + // version (see apps/mobile/CLAUDE.md). Neither is covered by this config. + "ignoreWorkspaces": ["apps/mobile"], + + "workspaces": { + ".": { + "entry": ["scripts/*.mjs"], + "project": ["scripts/*.mjs"] + }, + + "apps/web": { + "entry": ["eslint.config.mjs"], + // Referenced only by the `@type {import('postcss-load-config').Config}` + // JSDoc annotation in postcss.config.mjs. Declaring it would add a + // runtime dependency to satisfy a type comment. + "ignoreDependencies": ["postcss-load-config"] + }, + + "apps/desktop": { + // window-state.node-test.mjs is run by hand (`node --test`, see its + // header) rather than by a package script, so nothing imports it. + "entry": ["eslint.config.mjs", "src/main/*.node-test.mjs"], + // Consumed by `extends` in tsconfig.node.json / tsconfig.web.json. + // knip follows `extends` from the default tsconfig.json only. + "ignoreDependencies": ["@multica/tsconfig"] + }, + + "apps/docs": {}, + + // The three shared packages export raw .ts/.tsx compiled by their + // consumers (CLAUDE.md "Project Shape"), so `entry` names only the config + // files knip would otherwise miss and the apps supply the real entry + // points — a file is live when an app reaches it. + // + // That holds for views and core, whose `exports` name specific files + // (44 of views' 45). It does NOT hold for the four wildcard exports in + // packages/ui: knip expands each into a glob over its whole directory and + // registers every match as an entry point, so nothing under + // components/ui, components/common, markdown, or hooks can ever be + // reported unused here. `entry: []`, negated `entry` patterns, and + // `--production` were all tried — manifest entries come from a separate + // code path that none of them subtract from. + // + // scripts/check-ui-wildcard-exports.mjs covers exactly those four + // directories and runs as its own blocking CI step. Narrow a wildcard + // export and knip picks that directory back up; the script reads the same + // manifest, so the two stay in step without overlapping. + "packages/ui": { + "entry": ["eslint.config.mjs"], + "project": ["**/*.{ts,tsx}"] + }, + "packages/views": { + "entry": ["eslint.config.mjs"], + "project": ["**/*.{ts,tsx}"] + }, + "packages/core": { + "entry": ["eslint.config.mjs"], + "project": ["**/*.{ts,tsx}"] + }, + + "packages/eslint-config": {}, + "packages/tsconfig": {} + } +} diff --git a/package.json b/package.json index c31116a20..a16bbd702 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "typecheck": "turbo typecheck --filter=!@multica/mobile", "test": "turbo test --filter=!@multica/mobile", "lint": "turbo lint --filter=!@multica/mobile", + "knip": "knip", + "check:ui-exports": "node scripts/check-ui-wildcard-exports.mjs", "clean": "turbo clean && rm -rf node_modules", "ui:add": "cd packages/ui && npx shadcn@latest add", "generate:reserved-slugs": "node scripts/generate-reserved-slugs.mjs" @@ -48,6 +50,7 @@ "@playwright/test": "^1.58.2", "@types/node": "catalog:", "@types/pg": "^8.20.0", + "knip": "^6.32.2", "pg": "^8.20.0", "turbo": "^2.5.4", "typescript": "catalog:" diff --git a/packages/core/package.json b/packages/core/package.json index 9463ad276..e09368a6b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -159,7 +159,6 @@ "dependencies": { "@formatjs/intl-localematcher": "catalog:", "@tanstack/react-query": "catalog:", - "@tanstack/react-query-devtools": "^5.96.2", "i18next": "catalog:", "posthog-js": "catalog:", "react-i18next": "catalog:", diff --git a/packages/ui/components/common/mention-hover-card.tsx b/packages/ui/components/common/mention-hover-card.tsx deleted file mode 100644 index c1eedb3a1..000000000 --- a/packages/ui/components/common/mention-hover-card.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { Users } from "lucide-react"; -import { HoverCard, HoverCardTrigger, HoverCardContent } from "@multica/ui/components/ui/hover-card"; -import { ActorAvatar } from "./actor-avatar"; - -interface MentionHoverCardProps { - type: string; - id: string; - name: string; - initials: string; - avatarUrl?: string | null; - role?: string; - children: ReactNode; -} - -function MentionHoverCard({ - type, - id: _id, - name, - initials, - avatarUrl, - role, - children, -}: MentionHoverCardProps) { - if (type === "all") { - return ( - - } className="cursor-default"> - {children} - - -
-
- -
-
-

All members

-

Notifies all workspace members

-
-
-
-
- ); - } - - return ( - - } className="cursor-default"> - {children} - - -
- -
-

{name}

- {role && ( -

{role}

- )} -
-
-
-
- ); -} - -export { MentionHoverCard, type MentionHoverCardProps }; diff --git a/packages/ui/components/ui/accordion.tsx b/packages/ui/components/ui/accordion.tsx deleted file mode 100644 index 124c859f3..000000000 --- a/packages/ui/components/ui/accordion.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client" - -import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" - -import { cn } from "@multica/ui/lib/utils" -import { ChevronDownIcon, ChevronUpIcon } from "lucide-react" - -function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { - return ( - - ) -} - -function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { - return ( - - ) -} - -function AccordionTrigger({ - className, - children, - ...props -}: AccordionPrimitive.Trigger.Props) { - return ( - - - {children} - - - - - ) -} - -function AccordionContent({ - className, - children, - ...props -}: AccordionPrimitive.Panel.Props) { - return ( - -
- {children} -
-
- ) -} - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/packages/ui/components/ui/aspect-ratio.tsx b/packages/ui/components/ui/aspect-ratio.tsx deleted file mode 100644 index f0bdf5342..000000000 --- a/packages/ui/components/ui/aspect-ratio.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { cn } from "@multica/ui/lib/utils" - -function AspectRatio({ - ratio, - className, - ...props -}: React.ComponentProps<"div"> & { ratio: number }) { - return ( -
- ) -} - -export { AspectRatio } diff --git a/packages/ui/components/ui/breadcrumb.tsx b/packages/ui/components/ui/breadcrumb.tsx deleted file mode 100644 index 01072432b..000000000 --- a/packages/ui/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import * as React from "react" -import { mergeProps } from "@base-ui/react/merge-props" -import { useRender } from "@base-ui/react/use-render" - -import { cn } from "@multica/ui/lib/utils" -import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" - -function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { - return ( -