| 1 | import "vite-plus/test/config"; |
| 2 | import { defineConfig } from "vite-plus"; |
| 3 | import * as NodeURL from "node:url"; |
| 4 | |
| 5 | /** Import restrictions every file keeps, including the one module exempt from the glyph rule. */ |
| 6 | const RESTRICTED_IMPORT_PATHS = [ |
| 7 | { |
| 8 | name: "@t3tools/client-runtime", |
| 9 | message: |
| 10 | "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.", |
| 11 | }, |
| 12 | { |
| 13 | name: "@pierre/diffs/react", |
| 14 | importNames: ["CodeView"], |
| 15 | message: "Use StyledDiffCodeView so web diff surfaces share styling and virtualized geometry.", |
| 16 | }, |
| 17 | ]; |
| 18 | |
| 19 | /** |
| 20 | * The cva functions behind components/ui exports. They style a foreign element to look |
| 21 | * like a Button or Toggle, which bypasses the component's variants; render the component |
| 22 | * instead (`render={<Button …/>}`, or `SelectButton` for a picker trigger). |
| 23 | */ |
| 24 | const RESTRICTED_UI_VARIANT_PATTERNS = [ |
| 25 | { |
| 26 | group: ["**/components/ui/*", "**/ui/*", "./ui/*"], |
| 27 | importNames: ["buttonVariants", "toggleVariants", "badgeVariants", "selectTriggerVariants"], |
| 28 | message: |
| 29 | "Render the components/ui export instead of borrowing its class recipe (render={<Button …/>}, SelectButton, ToggleGroup).", |
| 30 | }, |
| 31 | ]; |
| 32 | |
| 33 | /** Lucide's pull-request glyphs, which only `pullRequestIcons.tsx` may name. */ |
| 34 | const RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS = { |
| 35 | name: "lucide-react", |
| 36 | importNames: [ |
| 37 | "GitMerge", |
| 38 | "GitMergeIcon", |
| 39 | "GitPullRequest", |
| 40 | "GitPullRequestIcon", |
| 41 | "GitPullRequestArrow", |
| 42 | "GitPullRequestArrowIcon", |
| 43 | "GitPullRequestClosed", |
| 44 | "GitPullRequestClosedIcon", |
| 45 | "GitPullRequestDraft", |
| 46 | "GitPullRequestDraftIcon", |
| 47 | "GitPullRequestCreate", |
| 48 | "GitPullRequestCreateIcon", |
| 49 | "GitPullRequestCreateArrow", |
| 50 | "GitPullRequestCreateArrowIcon", |
| 51 | ], |
| 52 | message: |
| 53 | "Pick a glyph by meaning from PullRequestGlyph in apps/web/src/components/pullRequest/pullRequestIcons.tsx so every surface draws the same pull request the same way.", |
| 54 | }; |
| 55 | |
| 56 | export default defineConfig({ |
| 57 | resolve: { |
| 58 | alias: { |
| 59 | "~": NodeURL.fileURLToPath(new URL("./apps/web/src", import.meta.url)), |
| 60 | }, |
| 61 | }, |
| 62 | test: { |
| 63 | environment: "node", |
| 64 | exclude: [ |
| 65 | "**/.repos/**", |
| 66 | "**/.t3/**", |
| 67 | "**/node_modules/**", |
| 68 | "**/dist/**", |
| 69 | "**/dist-electron/**", |
| 70 | "**/.{idea,git,cache,output,temp}/**", |
| 71 | ], |
| 72 | hookTimeout: 60_000, |
| 73 | testTimeout: 60_000, |
| 74 | setupFiles: [ |
| 75 | NodeURL.fileURLToPath( |
| 76 | new URL("./packages/shared/src/testing/longTempDir.ts", import.meta.url), |
| 77 | ), |
| 78 | ], |
| 79 | }, |
| 80 | staged: { |
| 81 | // Formatter only for now — no lint or typecheck on commit. |
| 82 | "*": "vp fmt --no-error-on-unmatched-pattern", |
| 83 | }, |
| 84 | fmt: { |
| 85 | ignorePatterns: [ |
| 86 | ".repos/**", |
| 87 | // Macroscope's glob-per-line ignore grammar, not Markdown: formatting |
| 88 | // it rewrites `*` as `_` and joins lines. |
| 89 | ".macroscope/ignore.md", |
| 90 | ".alchemy", |
| 91 | "dist", |
| 92 | "dist-electron", |
| 93 | "node_modules", |
| 94 | "pnpm-lock.yaml", |
| 95 | "*.tsbuildinfo", |
| 96 | "**/routeTree.gen.ts", |
| 97 | "apps/mobile/android/**", |
| 98 | "apps/mobile/ios/**", |
| 99 | "apps/mobile/uniwind-types.d.ts", |
| 100 | "*.icon/**", |
| 101 | ], |
| 102 | sortPackageJson: {}, |
| 103 | overrides: [ |
| 104 | { |
| 105 | files: [".devcontainer/devcontainer.json"], |
| 106 | options: { |
| 107 | trailingComma: "none", |
| 108 | }, |
| 109 | }, |
| 110 | ], |
| 111 | }, |
| 112 | lint: { |
| 113 | ignorePatterns: [ |
| 114 | ".repos", |
| 115 | ".repos/**", |
| 116 | "dist", |
| 117 | "dist-electron", |
| 118 | "node_modules", |
| 119 | "pnpm-lock.yaml", |
| 120 | "*.tsbuildinfo", |
| 121 | "**/routeTree.gen.ts", |
| 122 | "apps/mobile/android/**", |
| 123 | "apps/mobile/ios/**", |
| 124 | "apps/mobile/uniwind-types.d.ts", |
| 125 | ], |
| 126 | plugins: ["eslint", "oxc", "react", "unicorn", "typescript"], |
| 127 | jsPlugins: ["./oxlint-plugin-t3code/index.ts", "@shadcn/lint"], |
| 128 | settings: { |
| 129 | shadcn: { ui: "~/components/ui" }, |
| 130 | }, |
| 131 | categories: { |
| 132 | correctness: "warn", |
| 133 | suspicious: "warn", |
| 134 | perf: "warn", |
| 135 | }, |
| 136 | rules: { |
| 137 | "unicorn/no-array-sort": "off", |
| 138 | "unicorn/consistent-function-scoping": "off", |
| 139 | "oxc/no-map-spread": "off", |
| 140 | "react-in-jsx-scope": "off", |
| 141 | "react-hooks/exhaustive-deps": "off", |
| 142 | "eslint/no-shadow": "off", |
| 143 | "eslint/no-await-in-loop": "off", |
| 144 | "eslint/no-underscore-dangle": "off", |
| 145 | "typescript/consistent-return": "off", |
| 146 | "typescript/no-base-to-string": "off", |
| 147 | "typescript/no-duplicate-type-constituents": "off", |
| 148 | "typescript/no-floating-promises": "off", |
| 149 | "typescript/no-implied-eval": "off", |
| 150 | "typescript/no-meaningless-void-operator": "off", |
| 151 | "typescript/no-redundant-type-constituents": "off", |
| 152 | "typescript/no-unnecessary-boolean-literal-compare": "off", |
| 153 | "typescript/no-unnecessary-type-conversion": "off", |
| 154 | "typescript/no-unnecessary-type-arguments": "off", |
| 155 | "typescript/no-unnecessary-type-assertion": "off", |
| 156 | "typescript/no-unnecessary-type-parameters": "off", |
| 157 | "typescript/no-unsafe-type-assertion": "off", |
| 158 | "typescript/await-thenable": "off", |
| 159 | "typescript/require-array-sort-compare": "off", |
| 160 | "typescript/restrict-template-expressions": "off", |
| 161 | "typescript/unbound-method": "off", |
| 162 | "eslint/no-restricted-imports": [ |
| 163 | "error", |
| 164 | { paths: [...RESTRICTED_IMPORT_PATHS, RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS] }, |
| 165 | ], |
| 166 | "t3code/no-global-process-runtime": "error", |
| 167 | "t3code/no-inline-schema-compile": "warn", |
| 168 | "t3code/no-manual-effect-runtime-in-tests": "error", |
| 169 | "t3code/no-native-title-tooltip": "error", |
| 170 | "t3code/no-raw-mcp-registration": "error", |
| 171 | "t3code/no-test-in-loop": "error", |
| 172 | "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: true }], |
| 173 | "t3code/no-unscoped-has": "error", |
| 174 | "t3code/namespace-node-imports": "error", |
| 175 | "t3code/prefer-catch-tags": "error", |
| 176 | "t3code/require-centered-scroll-gutter": "error", |
| 177 | "t3code/require-suppression-reason": "error", |
| 178 | }, |
| 179 | overrides: [ |
| 180 | { |
| 181 | files: ["packages/client-runtime/src/state/**", "apps/{web,mobile,desktop}/src/**"], |
| 182 | rules: { "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: false }] }, |
| 183 | }, |
| 184 | { |
| 185 | // Only shared command boundaries install the session-backed permission guard. |
| 186 | files: [ |
| 187 | "packages/client-runtime/src/state/runtime.ts", |
| 188 | "packages/client-runtime/src/state/vcsAction.ts", |
| 189 | ], |
| 190 | rules: { |
| 191 | "t3code/no-rpc-permission-bypass": [ |
| 192 | "error", |
| 193 | { allowGuardInstallation: true, allowRawClientAccess: false }, |
| 194 | ], |
| 195 | }, |
| 196 | }, |
| 197 | { |
| 198 | // These clients are session metadata, device streams, and an Expo update adapter. |
| 199 | files: [ |
| 200 | "apps/web/src/components/settings/ConnectionsSettings.tsx", |
| 201 | "apps/mobile/src/features/updates/app-updates.ts", |
| 202 | "apps/web/src/components/device/DevicePhoneViewport.tsx", |
| 203 | "apps/web/src/components/device/DeviceDuoViewport.tsx", |
| 204 | ], |
| 205 | rules: { "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: true }] }, |
| 206 | }, |
| 207 | { |
| 208 | // Incompatible hosts cannot open a normal session; their updater uses a dedicated socket. |
| 209 | files: ["packages/client-runtime/src/connection/outdatedHostUpdate.ts"], |
| 210 | rules: { "t3code/no-rpc-permission-bypass": "off" }, |
| 211 | }, |
| 212 | { |
| 213 | // RPC implementation and transport test fixtures need the raw client. |
| 214 | files: [ |
| 215 | "packages/client-runtime/src/rpc/**", |
| 216 | "**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}", |
| 217 | ], |
| 218 | rules: { "t3code/no-rpc-permission-bypass": "off" }, |
| 219 | }, |
| 220 | { |
| 221 | // The one place that reads the host platform to seed the injected references. |
| 222 | files: ["packages/shared/src/HostProcess.ts"], |
| 223 | rules: { "t3code/no-global-process-runtime": "off" }, |
| 224 | }, |
| 225 | { |
| 226 | // The registration helpers that only accept handlers built by McpToolAccess. |
| 227 | files: ["apps/server/src/mcp/McpHttpServer.ts"], |
| 228 | rules: { "t3code/no-raw-mcp-registration": "off" }, |
| 229 | }, |
| 230 | { |
| 231 | files: ["apps/web/src/**"], |
| 232 | excludeFiles: ["apps/web/src/components/ui/**"], |
| 233 | rules: { |
| 234 | "eslint/no-restricted-imports": [ |
| 235 | "error", |
| 236 | { |
| 237 | paths: [...RESTRICTED_IMPORT_PATHS, RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS], |
| 238 | patterns: RESTRICTED_UI_VARIANT_PATTERNS, |
| 239 | }, |
| 240 | ], |
| 241 | }, |
| 242 | }, |
| 243 | { |
| 244 | // The one module allowed to name lucide's pull-request glyphs; everything else picks |
| 245 | // from its vocabulary. The other import restrictions still apply here. |
| 246 | files: ["apps/web/src/components/pullRequest/pullRequestIcons.tsx"], |
| 247 | rules: { "eslint/no-restricted-imports": ["error", { paths: RESTRICTED_IMPORT_PATHS }] }, |
| 248 | }, |
| 249 | { |
| 250 | files: ["apps/mobile/src/**"], |
| 251 | rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, |
| 252 | }, |
| 253 | { |
| 254 | // React commits state set in a raw ResizeObserver callback after the paint, so derived |
| 255 | // layout lands a frame late. observeResize flushes every observed resize in one render. |
| 256 | files: ["apps/web/src/**"], |
| 257 | excludeFiles: [ |
| 258 | "apps/web/src/lib/observeResize.ts", |
| 259 | "**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}", |
| 260 | ], |
| 261 | rules: { "t3code/no-raw-resize-observer": "error" }, |
| 262 | }, |
| 263 | { |
| 264 | // Every class in web code must be one Tailwind generates: a typo or a class nothing |
| 265 | // declares ships silently unstyled. JS hooks use data attributes, not class names. |
| 266 | files: ["apps/web/src/**"], |
| 267 | rules: { "shadcn/no-unknown-classes": "error" }, |
| 268 | }, |
| 269 | { |
| 270 | // State indicators paint inward; decorative rings keep their own geometry. |
| 271 | files: ["apps/web/src/**"], |
| 272 | rules: { "t3code/no-outset-state-indicators": "error" }, |
| 273 | }, |
| 274 | { |
| 275 | // Colors come from theme tokens so status tones follow custom themes. components/ui |
| 276 | // has no findings and stays covered too. |
| 277 | files: ["apps/web/src/**"], |
| 278 | rules: { "shadcn/no-raw-colors": "error" }, |
| 279 | }, |
| 280 | { |
| 281 | // Third-party marks (brand logos, the macOS permission panes, Codex's Computer Use |
| 282 | // mark) must keep their exact colors, so the files that hold them are exempt. |
| 283 | files: ["apps/web/src/components/Icons.tsx", "apps/web/src/components/JetBrainsIcons.tsx"], |
| 284 | rules: { "shadcn/no-raw-colors": "off" }, |
| 285 | }, |
| 286 | { |
| 287 | // components/ui exports own their look. App code picks a variant or size instead |
| 288 | // of restyling with className; layout classes (width, flex, margin, position) stay |
| 289 | // allowed because placement belongs to the parent. components/ui is for generic |
| 290 | // primitives: a look that belongs to one feature stays in that feature's component. |
| 291 | files: ["apps/web/src/**"], |
| 292 | excludeFiles: ["apps/web/src/components/ui/**"], |
| 293 | rules: { |
| 294 | // A className built at runtime on a ui component is one no-restyle cannot read. |
| 295 | "shadcn/require-static-classes": "error", |
| 296 | // Appearance values come from the theme and Tailwind's scales. Layout stays free |
| 297 | // (placement belongs to the parent); the other entries are values no scale can hold. |
| 298 | "shadcn/no-arbitrary-values": [ |
| 299 | "error", |
| 300 | { |
| 301 | allow: [ |
| 302 | "layout", |
| 303 | // Which properties an element animates is per-element behaviour, like layout, |
| 304 | // not a design value; timing curves and durations still come from the theme. |
| 305 | "transition", |
| 306 | // Overlays that follow their frame's corner, which is set at runtime |
| 307 | // (floating preview) or by the element they decorate (composer outline). |
| 308 | "rounded-[inherit]", |
| 309 | // Inline chips size in em so they scale with the text they sit in |
| 310 | // (the composer honours the prompt font-size preference). |
| 311 | "gap-[0.33em]", |
| 312 | "px-[0.5em]", |
| 313 | "rounded-[0.5em]", |
| 314 | "text-[0.86em]", |
| 315 | // Project icons render from 14px to 48px and keep one proportional corner. |
| 316 | "rounded-[25%]", |
| 317 | // An emoji project icon fills its container, whatever size the parent gives it. |
| 318 | "text-[length:80cqh]", |
| 319 | // The platform's own selection colour on a selected composer chip. |
| 320 | "bg-[Highlight]", |
| 321 | // Brand marks keep their brand colours (Cursor, Grok, Claude). |
| 322 | "fill-[#26251E]", |
| 323 | "fill-[#EDECEC]", |
| 324 | "fill-[#0F0F0F]", |
| 325 | "fill-[#F5F5F5]", |
| 326 | "fill-[#d97757]", |
| 327 | "text-[#d97757]", |
| 328 | ], |
| 329 | }, |
| 330 | ], |
| 331 | "shadcn/no-restyle": [ |
| 332 | "error", |
| 333 | { |
| 334 | allow: ["layout"], |
| 335 | contracts: [ |
| 336 | { |
| 337 | // CollapsibleTrigger is a bare button with no styled counterpart |
| 338 | // (a disclosure row is not a Button), so its className is the API. |
| 339 | // Every other trigger has one: style them with render={<Button …/>}. |
| 340 | pattern: "^CollapsibleTrigger$", |
| 341 | allow: ["layout", "color", "typography", "spacing", "shape", "effects", "motion"], |
| 342 | }, |
| 343 | ], |
| 344 | }, |
| 345 | ], |
| 346 | }, |
| 347 | }, |
| 348 | { |
| 349 | // The sign-in masthead is T3 brand artwork: fixed gradients, not theme surfaces. |
| 350 | files: ["apps/web/src/components/auth/AuthSurfaceShell.tsx"], |
| 351 | rules: { "shadcn/no-arbitrary-values": "off" }, |
| 352 | }, |
| 353 | { |
| 354 | // Shared client code must not call APIs missing from Hermes. Our ESNext |
| 355 | // TypeScript target accepts them even when they would crash mobile at launch. |
| 356 | // Tests run on Node and are exempt. |
| 357 | files: [ |
| 358 | "apps/mobile/src/**", |
| 359 | "packages/client-runtime/src/**", |
| 360 | "packages/contracts/src/**", |
| 361 | "packages/shared/src/**", |
| 362 | ], |
| 363 | excludeFiles: ["**/*.test.ts", "**/*.test.tsx"], |
| 364 | rules: { "t3code/no-hermes-unsupported-apis": "error" }, |
| 365 | }, |
| 366 | { |
| 367 | // Reviewed native and third-party interop boundaries that cannot consume a className. |
| 368 | files: [ |
| 369 | "apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx", |
| 370 | "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx", |
| 371 | "apps/mobile/src/features/files/FileMarkdownPreview.tsx", |
| 372 | "apps/mobile/src/features/files/SourceFileSurface.tsx", |
| 373 | "apps/mobile/src/features/files/AttachmentFileScreen.tsx", |
| 374 | "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx", |
| 375 | "apps/mobile/src/features/files/thread-file-navigator-pane.tsx", |
| 376 | "apps/mobile/src/features/home/HomeHeader.tsx", |
| 377 | "apps/mobile/src/features/review/ReviewSheet.tsx", |
| 378 | "apps/mobile/src/features/review/useNativeReviewDiffBridge.ts", |
| 379 | "apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx", |
| 380 | "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx", |
| 381 | "apps/mobile/src/features/threads/NewTaskDraftScreen.tsx", |
| 382 | "apps/mobile/src/features/threads/ThreadComposer.tsx", |
| 383 | "apps/mobile/src/features/threads/ThreadFeed.tsx", |
| 384 | "apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx", |
| 385 | "apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx", |
| 386 | "apps/mobile/src/features/threads/ThreadQueueControl.tsx", |
| 387 | "apps/mobile/src/features/threads/ThreadAgentsSheet.tsx", |
| 388 | "apps/mobile/src/features/review/ReviewCommentCard.tsx", |
| 389 | "apps/mobile/src/features/threads/ThreadSettingsSheet.tsx", |
| 390 | "apps/mobile/src/features/threads/git/GitOverviewSheet.tsx", |
| 391 | "apps/mobile/src/features/threads/thread-list-items.tsx", |
| 392 | "apps/mobile/src/features/threads/thread-list-v2-items.tsx", |
| 393 | "apps/mobile/src/lib/useMobileNavigationTheme.ts", |
| 394 | "apps/mobile/src/native/T3ComposerEditor.ios.tsx", |
| 395 | "apps/mobile/src/native/T3ComposerEditor.native.tsx", |
| 396 | "apps/mobile/src/native/SelectableMarkdownText.tsx", |
| 397 | ], |
| 398 | rules: { |
| 399 | "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], |
| 400 | }, |
| 401 | }, |
| 402 | ], |
| 403 | options: { |
| 404 | reportUnusedDisableDirectives: "error", |
| 405 | // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics. |
| 406 | typeAware: false, |
| 407 | typeCheck: false, |
| 408 | }, |
| 409 | }, |
| 410 | }); |