-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy patheslint.config.js
More file actions
342 lines (334 loc) · 18.5 KB
/
Copy patheslint.config.js
File metadata and controls
342 lines (334 loc) · 18.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
// ESLint flat config for peanut-ui (Next 16 / React 19 / TypeScript).
// Baseline rules: TypeScript recommended, React recommended, React Hooks, Next.js.
// Plus a project-specific rule banning bare router.back() outside useSafeBack (PR #1965).
const tsParser = require('@typescript-eslint/parser')
const tsPlugin = require('@typescript-eslint/eslint-plugin')
const reactPlugin = require('eslint-plugin-react')
const reactHooksPlugin = require('eslint-plugin-react-hooks')
const nextPlugin = require('@next/eslint-plugin-next')
const importPlugin = require('eslint-plugin-import-x')
const globals = require('globals')
const copyPropsFromCatalog = require('./eslint-rules/copy-props-from-catalog')
// Barrel paths banned by CLAUDE.md ("no barrel imports — never `import * as X from
// '@/constants'` or create `index.ts` barrels. Import from specific files"). The bare
// alias resolves to `<dir>/index.{ts,tsx}` which forces the bundler to load every
// re-export, hurting build perf. Existing violations remain (~135 across the codebase)
// — the guard is preventative; cleanup belongs in a separate sweep.
const BANNED_BARREL_PATHS = ['@/constants', '@/components', '@/assets', '@/context', '@/interfaces', '@/config']
module.exports = [
{
ignores: [
'.next/**',
'out/**',
'dist/**',
'node_modules/**',
'public/**',
'src/content/**',
'android/**',
'ios/**',
'build/**',
'src/types/api.generated.ts',
'coverage/**',
'playwright-report/**',
'test-results/**',
// Submodule + generated
'engineering/**',
'src/assets/**',
],
},
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true } },
globals: { ...globals.browser, ...globals.node },
},
plugins: {
'@typescript-eslint': tsPlugin,
react: reactPlugin,
'react-hooks': reactHooksPlugin,
'@next/next': nextPlugin,
'import-x': importPlugin,
},
settings: {
react: { version: 'detect' },
'import-x/resolver': {
typescript: { project: './tsconfig.json' },
node: true,
},
},
rules: {
...tsPlugin.configs.recommended.rules,
...reactPlugin.configs.recommended.rules,
...reactHooksPlugin.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs['core-web-vitals'].rules,
// Prefix with `_` to mark an intentionally-unused binding.
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
// React 17+ — no need to import React for JSX
'react/react-in-jsx-scope': 'off',
'react/jsx-uses-react': 'off',
// We use TypeScript for prop validation
'react/prop-types': 'off',
// Allow unescaped quotes — too noisy and prettier handles spacing
'react/no-unescaped-entities': 'off',
// `jsx`/`global` are styled-jsx's <style> attributes (built into Next), not DOM props.
'react/no-unknown-property': ['error', { ignore: ['jsx', 'global'] }],
// Ban barrel imports — see BANNED_BARREL_PATHS above.
'no-restricted-imports': [
'error',
{
paths: BANNED_BARREL_PATHS.map((path) => ({
name: path,
message: `Import from a specific file instead of the '${path}' barrel — barrels force the bundler to load every re-export and hurt build perf. See CLAUDE.md.`,
})),
},
],
// Ban self-imports — CLAUDE.md import rules. Confirmed firing on synthetic test.
'import-x/no-self-import': 'error',
// import-x/no-cycle is intentionally NOT enabled. The plugin's no-cycle silently
// fails on synthetic A↔B cycles under ESLint 9 flat config in this setup —
// verified against both eslint-plugin-import 2.32 and eslint-plugin-import-x 4.16.
// Self-imports are still caught above. Revisit when the plugin matures or someone
// figures out the resolver gotcha.
// Project-specific: catch the back-button bug class.
// See src/hooks/useSafeBack.ts, PR #1965 (router.back), PR #1997 (sibling patterns).
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.object.name='router'][callee.property.name='back']",
message:
"Don't call router.back() directly — it no-ops on deep-link entries (cold tab, QR scan, push notification). Use useSafeBack(fallbackUrl) from '@/hooks/useSafeBack' instead. See PR #1965.",
},
{
// Only matches the simple () => router.push|replace(x) arrow-body shape —
// multi-statement handlers (state resets, conditional branches) keep their
// freedom since they often combine navigation with intentional side effects.
selector:
"JSXAttribute[name.name=/^(onPrev|onBack)$/] > JSXExpressionContainer > ArrowFunctionExpression[body.type='CallExpression'][body.callee.object.name='router'][body.callee.property.name=/^(push|replace)$/]",
message:
'Bare router.push/replace as onPrev/onBack creates a parent↔child cycle once the parent uses useSafeBack (the push grows in-app history, useSafeBack pops back to this screen, repeat). Use useSafeBack(parentUrl) — pass { replace: true } to preserve replace semantics. See PR #1997.',
},
{
selector:
"MemberExpression[object.object.name='window'][object.property.name='history'][property.name='length']",
message:
"window.history.length is the pre-useSafeBack idiom (history.length > 1 ? back : push). It misfires on cold-load from external referrers — useSafeBack's pushState counter is more accurate. See PR #1965.",
},
{
// nuqs `history: 'push'` stacks a browser-history entry on every URL write.
// For per-keystroke params (e.g. `amount`) that poisons the back stack:
// useSafeBack → router.back() then steps through stale same-screen states
// and the back button looks dead (add-money MP/bank reports, June 2026).
selector:
"CallExpression[callee.name=/^useQueryStates?$/] Property[key.name='history'][value.value='push']",
message:
"Don't pass { history: 'push' } to nuqs useQueryState(s) — a history entry per URL write breaks the back button (useSafeBack steps through same-screen states instead of leaving). Use the default 'replace'; the URL stays shareable. If a flow genuinely needs push-per-step, add a scoped file exemption with a comment (see useNativePlugins).",
},
{
// Toast copy must come from next-intl. `react/jsx-no-literals` below
// only inspects JSX children, so toasts fired from hooks and contexts
// (authContext, useLogin, useSendMoney, QRScanner) shipped English to
// every locale unnoticed.
//
// Deliberately NOT extended to `throw new Error('…')`: those messages
// are developer/Sentry breadcrumbs that the friendly-error mapper
// collapses to `errors.genericSupport` before any user sees them, so
// translating them would only fragment Sentry issue grouping.
selector:
"CallExpression[callee.object.name='toast'][callee.property.name=/^(error|success|info|warning|loading)$/] > :matches(Literal, TemplateLiteral):first-child",
message:
"Don't pass a string literal to toast.* — copy must come from next-intl. Import the right namespace with useTranslations and pass t('…'). If the value genuinely isn't copy (an id, a URL), assign it to a named const first.",
},
{
// iOS has never implemented the Vibration API — not in any version,
// Safari or WKWebView — so navigator.vibrate() is a permanent no-op
// there, and the `'vibrate' in navigator` guard that usually wraps it
// makes the failure completely silent. On Android it works but only
// above a duration threshold no call site was passing. Every native
// haptic in the app was dead this way until 1.0.48.
selector: "CallExpression[callee.object.name='navigator'][callee.property.name='vibrate']",
message:
"Don't call navigator.vibrate() directly — it is a permanent no-op on iOS (no Vibration API in any version) and silently does nothing. Use notifyHaptic / impactHaptic / vibrateHaptic / cancelHaptic from '@/utils/haptics', which drive @capacitor/haptics on native, or useAppHaptic() from '@/hooks/useAppHaptic' for a light tap in a component.",
},
{
// Settling a promise WITH a Capacitor plugin object probes its .then,
// and the registerPlugin proxy answers any property with a
// native-method wrapper that never invokes the callbacks it is handed
// — so the promise stays pending forever and even the .catch is dead.
// Shipped twice: getPreferences() (1.0.44) and the Crisp helper
// (1.0.45–1.0.47). Return { Plugin } instead.
selector: 'ReturnStatement > Identifier[name=/^(Capacitor[A-Z]|Preferences$)/]',
message:
'Never return a Capacitor plugin object across an await/then boundary — resolving a promise with it probes .then, which the plugin proxy turns into a native call that never settles the promise. Wrap it: `return { Plugin }` and destructure at the call site. See src/utils/crisp.ts and src/utils/auth-token.ts.',
},
],
},
},
{
// The hook itself wraps router.back() — exempt.
files: ['src/hooks/useSafeBack.ts', 'src/hooks/__tests__/useSafeBack.test.ts'],
rules: { 'no-restricted-syntax': 'off' },
},
{
// The one module allowed to touch the Vibration API: it is the web
// fallback behind the haptics helpers everything else must use.
files: ['src/utils/haptics.ts'],
rules: { 'no-restricted-syntax': 'off' },
},
{
// Capacitor hardware back: different bug class (canGoBack + minimizeApp).
files: ['src/hooks/useNativePlugins.ts'],
rules: { 'no-restricted-syntax': 'off' },
},
{
// PublicProfile is the one place we intentionally keep an isInternalReferrer +
// window.history.length check. The referrer signal is orthogonal to useSafeBack's
// counter; migrating loses information for external-referrer cold-loads.
files: ['src/components/Profile/components/PublicProfile.tsx'],
rules: { 'no-restricted-syntax': 'off' },
},
{
// require() inside test bodies is the Jest idiom for reading mocks after
// jest.mock()/resetModules(); hoisting them to imports changes semantics.
// no-img-element: these files mock next/image down to a raw <img>.
// no-explicit-any: mocks and partial fixtures legitimately cast through
// `any` — production code keeps the ban.
files: ['src/**/__tests__/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}', 'src/**/__mocks__/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@next/next/no-img-element': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
// Dev-only tooling: /dev pages, the window.debug console cheats, and the
// InvitesGraph debug visualization. The cheat API is intrinsically dynamic
// and d3/force-graph mutate node objects at runtime — typing them buys no
// user-facing safety. Production code keeps the any ban.
files: ['src/app/(mobile-ui)/dev/**', 'src/context/PeanutDebug.tsx', 'src/components/Global/InvitesGraph/**'],
rules: { '@typescript-eslint/no-explicit-any': 'off' },
},
{
// OG images render through Satori (next/og ImageResponse), which supports
// only a subset of HTML/CSS and cannot render next/image — raw <img> with
// explicit width/height is the required form here, not an oversight.
files: ['src/components/og/**', 'src/app/api/og/**'],
rules: { '@next/next/no-img-element': 'off' },
},
{
// Rasterized to PNG by html-to-image (see share-asset/captureShareAsset.ts).
// next/image's lazy loading and wrapper markup break the capture — the same
// class of bug as the runtime <canvas> that file already documents.
files: ['src/components/Card/share-asset/**', 'src/components/Global/ImageGeneration/**'],
rules: { '@next/next/no-img-element': 'off' },
},
{
// Localization guard: product-UI copy must come from next-intl, not JSX
// literals. Scoped to the translated surface — marketing (its own i18n),
// the /dev design-system catalog, and shared primitives that receive copy
// as props are excluded. allowedStrings covers the symbols/masks that are
// not translatable copy (card masks, %, currency glyphs, arrows).
files: [
'src/app/(mobile-ui)/**/*.tsx',
'src/app/(setup)/**/*.tsx',
// Top-level app routes sit outside the route groups above; without
// them listed the guard cannot see their copy (/shhhhh shipped
// English-only to every locale because of exactly that gap).
'src/app/shhhhh/**/*.tsx',
'src/app/kyc/**/*.tsx',
'src/app/invite/**/*.tsx',
'src/components/{Home,Send,Request,Profile,Setup,Settings,Card,AddMoney,AddWithdraw,Withdraw,Claim,Payment,Points,Badges,Notifications,Invites,TransactionDetails,Kyc,IdentityVerification,ExchangeRate,Common,ForceIOSPWAInstall,User,Migration}/**/*.tsx',
'src/components/Global/**/*.tsx',
'src/features/**/*.tsx',
],
ignores: [
'src/app/(mobile-ui)/dev/**',
'src/**/__tests__/**',
'src/**/*.test.tsx',
// Marketing-shared Global components render on marketing pages (whose
// locale comes from the URL, not the app context) — they keep English
// and take any product-UI copy as props. FAQs/ExchangeRateWidget are
// imported by LandingPage and Marketing/mdx; Loading is a spinner
// fallback reached through the shared 0_Bruddle/Button.
'src/components/Global/{Layout,AnimateOnView,MarqueeWrapper,FAQs,FooterVisibilityObserver,ExchangeRateWidget,Modal,Loading}/**',
'src/components/Global/{Layout,AnimateOnView,MarqueeWrapper,FAQs,FooterVisibilityObserver,ExchangeRateWidget,Modal,Loading}.tsx',
'src/components/Global/{PeanutLoading,Icons}/**',
// InvitesGraph is a /dev-only debug visualization, not user-facing UI.
'src/components/Global/InvitesGraph/**',
// The payment network explorer is a team-gated /dev tool; its copy is
// intentionally English-only.
'src/features/payment-network-explorer/**',
// Hidden support tool — never linked in-app; support DMs the URL to
// affected users, so the copy stays English-only.
'src/app/(mobile-ui)/fix-card-signature/**',
],
plugins: { local: { rules: { 'copy-props-from-catalog': copyPropsFromCatalog } } },
rules: {
// Companion to jsx-no-literals below, which only sees JSX children:
// this catches copy handed to a component as a prop.
'local/copy-props-from-catalog': 'error',
'react/jsx-no-literals': [
'error',
{
noStrings: false,
ignoreProps: true,
allowedStrings: [
'•',
'·',
'%',
'$',
'(',
')',
'-',
'/',
':',
'#',
'+',
'×',
'→',
'←',
// ordered step markers on /shhhhh's two-door section
'01',
'02',
',',
'.',
'*',
'≈',
'≈ $',
'USD',
'R$',
'EVM',
'Solana',
'Tron',
// non-copy glyphs: card-number masks, percentages, decorative
// emoji, amount prefixes, and the brand URL stem
'****',
'••••',
'????',
'???? ???? ???? ????',
'??/??',
'100%',
'0%',
'120%',
'+$',
'✨',
'⭐',
'peanut.me/',
'i',
'version:',
],
},
],
},
},
]