@alveole/eslint-config 1.9.0 → 2.0.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,3 +10,21 @@ Dans un workspace:
10
10
  // eslint.config.js
11
11
  module.exports = require('@alveole/eslint-config');
12
12
  ```
13
+
14
+ ## Seuils de complexité et gel fermé
15
+
16
+ Les règles de complexité sont posées au niveau visé, pas au niveau constaté. La dette
17
+ existante a été gelée le temps de l'absorber, dans le `eslint-suppressions.json` de chaque
18
+ workspace. **Les quatre workspaces sont désormais à zéro, et le gel est fermé** : la CI
19
+ échoue si un `eslint-suppressions.json` retient la moindre offense.
20
+
21
+ ```sh
22
+ pnpm run check:suppressions
23
+ ```
24
+
25
+ Conséquence pour qui écrit du code ici : une violation se corrige, elle ne se gèle plus.
26
+ `--suppress-all` et `--suppress-rule` ne sont plus une issue, et un `eslint-disable` inline
27
+ est une décision de revue, pas un réflexe.
28
+
29
+ Si une règle s'avère mal calibrée, c'est la règle qui se discute, dans ce paquet, pour tout
30
+ le monde à la fois. Geler une exception au cas par cas revenait à la discuter nulle part.
package/index.js CHANGED
@@ -3,14 +3,36 @@ const expoConfig = require('eslint-config-expo/flat');
3
3
  const sharedRules = require('./rules/_shared');
4
4
  const styleRules = require('./rules/styles');
5
5
  const serviceRules = require('./rules/services');
6
+ const accessibilityRules = require('./rules/accessibility');
7
+ const complexityRules = require('./rules/complexity');
8
+ const sonarjsRules = require('./rules/sonarjs');
9
+ const catalogueRules = require('./rules/catalogues');
10
+ const configurationRules = require('./rules/configuration');
11
+ const chargementParesseuxRules = require('./rules/chargement-paresseux');
12
+ const testRules = require('./rules/tests');
13
+ const typageRules = require('./rules/typage');
14
+ const directiveRules = require('./rules/directives');
6
15
 
7
16
  /** @type {import('eslint').Linter.Config[]} */
8
17
  const config = defineConfig([
9
18
  expoConfig,
10
19
  { ignores: ['dist/**', '**/public/pdf.*.mjs'] },
20
+ // Une directive qui ne désactive plus rien est un vestige : elle laisse croire qu'une règle
21
+ // gêne encore à cet endroit, et elle survit au correctif qui l'a rendue inutile. Deux des huit
22
+ // directives levées ici ont été repérées exactement comme ça.
23
+ { linterOptions: { reportUnusedDisableDirectives: 'error' } },
11
24
  sharedRules,
12
25
  styleRules,
13
26
  serviceRules,
27
+ accessibilityRules,
28
+ complexityRules,
29
+ sonarjsRules,
30
+ catalogueRules,
31
+ configurationRules,
32
+ chargementParesseuxRules,
33
+ testRules,
34
+ typageRules,
35
+ directiveRules,
14
36
  ]);
15
37
 
16
38
  module.exports = config;
package/package.json CHANGED
@@ -1,30 +1,30 @@
1
1
  {
2
2
  "name": "@alveole/eslint-config",
3
- "version": "1.9.0",
3
+ "version": "2.0.0-alpha",
4
4
  "description": "ESLint flat config pour les projets Alveole",
5
5
  "main": "index.js",
6
- "scripts": {
7
- "build": "echo \"Nothing to build for @alveole/eslint-config\"",
8
- "lint": "eslint .",
9
- "typecheck": "echo \"Nothing to typecheck for @alveole/eslint-config\""
10
- },
11
6
  "files": [
12
7
  "index.js",
13
8
  "rules",
14
9
  "README.md"
15
10
  ],
16
11
  "peerDependencies": {
17
- "eslint": "^9.0.0"
12
+ "eslint": "^9.0.0 || ^10.0.0"
18
13
  },
19
14
  "dependencies": {
20
- "@uiw/react-signature": "1.3.5",
21
- "eslint-config-expo": "57.0.1",
22
- "react-native-signature-canvas": "5.1.1"
15
+ "@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
16
+ "eslint-config-expo": "57.0.2",
17
+ "eslint-plugin-sonarjs": "4.2.2"
23
18
  },
24
19
  "devDependencies": {
25
- "eslint": "9.39.5"
20
+ "eslint": "10.10.0"
26
21
  },
27
22
  "publishConfig": {
28
23
  "access": "public"
24
+ },
25
+ "scripts": {
26
+ "build": "echo \"Nothing to build for @alveole/eslint-config\"",
27
+ "lint": "eslint .",
28
+ "typecheck": "echo \"Nothing to typecheck for @alveole/eslint-config\""
29
29
  }
30
- }
30
+ }
package/rules/_shared.js CHANGED
@@ -129,6 +129,19 @@ const config = {
129
129
  'JSXAttribute[name.name=/^(justifyContent|justifyItems|justifySelf|alignItems|alignContent|alignSelf)$/]',
130
130
  message: "Cette prop CSS ne fonctionne pas directement sur Box. Passer par style={{ justifyContent: '...' }}.",
131
131
  },
132
+ {
133
+ // Une double assertion `x as unknown as T` (ou `as any as T`) passe par un type qui
134
+ // accepte tout, donc TypeScript ne verifie plus rien entre `x` et `T` : on peut lui
135
+ // faire affirmer n'importe quoi. C'est exactement ce qui a laisse passer un faux
136
+ // `ResizeObserver` a la signature fausse sans que le typecheck bronche. La regle ne
137
+ // l'interdit pas absolument - elle le fait remonter en revue : chaque contournement du
138
+ // typage doit etre un choix visible, jamais un reflexe. Les neuf occurrences du catalogue
139
+ // venaient toutes d'un meme type menteur (`StoryModule`) : avant de contourner, verifier
140
+ // que ce n'est pas le type qu'il faut corriger.
141
+ selector: 'TSAsExpression > TSAsExpression[typeAnnotation.type=/^TS(Unknown|Any)Keyword$/]',
142
+ message:
143
+ 'Double assertion `as unknown as` / `as any as` : elle desactive la verification de type sur cette expression. Preferer une valeur correctement typee ; si le contournement est indispensable, le justifier en revue.',
144
+ },
132
145
  ],
133
146
  'no-redeclare': 'off',
134
147
  '@typescript-eslint/no-redeclare': ['error', { ignoreDeclarationMerge: true }],
@@ -141,6 +154,14 @@ const config = {
141
154
  message: 'N’utilisez pas window.open. Utilisez Linking.openURL (expo) à la place.',
142
155
  },
143
156
  ],
157
+ // Les cycles d'imports cassent silencieusement le runtime (modules partiellement
158
+ // initialisés, `undefined` au lieu d'un composant) et ne se voient ni au typecheck ni
159
+ // au build. La règle verrouille le travail de décyclage déjà fait.
160
+ // disableScc: le graphe de composantes fortement connexes fait tomber la règle en
161
+ // TypeError quand un chemin n'est pas résolvable (no-cycle.js lit `scc[myPath]` sans
162
+ // vérifier le null retourné par scc.js), et coûte ici deux fois plus cher qu'il ne
163
+ // rapporte. Sans lui la détection est identique, mesurée sur un cycle réintroduit.
164
+ 'import/no-cycle': ['error', { maxDepth: 6, ignoreExternal: true, disableScc: true }],
144
165
  'no-unused-vars': 'off',
145
166
  '@typescript-eslint/no-unused-vars': [
146
167
  'error',
@@ -0,0 +1,84 @@
1
+ const TOUCHABLE_ELEMENTS = new Set([
2
+ 'Pressable',
3
+ 'TouchableOpacity',
4
+ 'TouchableHighlight',
5
+ 'TouchableWithoutFeedback',
6
+ 'TouchableNativeFeedback',
7
+ ]);
8
+
9
+ const accessibilityPlugin = {
10
+ rules: {
11
+ 'touchable-needs-role': {
12
+ meta: {
13
+ type: 'problem',
14
+ docs: {
15
+ description:
16
+ 'Impose un accessibilityRole sur les zones tactiles, sans quoi les lecteurs d’écran les annoncent comme du texte inerte.',
17
+ },
18
+ messages: {
19
+ missingRole:
20
+ '<{{name}}> n’expose aucun accessibilityRole : les lecteurs d’écran ne l’annonceront pas comme actionnable. Ajoutez accessibilityRole (« button », « link », « checkbox »…), ou accessible={false} si la zone n’est pas actionnable (fermeture du clavier, voile de modale).',
21
+ },
22
+ schema: [],
23
+ },
24
+ create(context) {
25
+ return {
26
+ JSXOpeningElement(node) {
27
+ if (node.name.type !== 'JSXIdentifier' || !TOUCHABLE_ELEMENTS.has(node.name.name)) {
28
+ return;
29
+ }
30
+
31
+ // Un spread peut porter accessibilityRole : le composant relaie alors les props
32
+ // que son appelant lui passe, et la règle n’a pas de quoi trancher.
33
+ const hasSpread = node.attributes.some(attribute => attribute.type === 'JSXSpreadAttribute');
34
+ if (hasSpread) {
35
+ return;
36
+ }
37
+
38
+ const findAttribute = name =>
39
+ node.attributes.find(
40
+ attribute =>
41
+ attribute.type === 'JSXAttribute' &&
42
+ attribute.name.type === 'JSXIdentifier' &&
43
+ attribute.name.name === name,
44
+ );
45
+
46
+ if (findAttribute('accessibilityRole')) {
47
+ return;
48
+ }
49
+
50
+ // Toutes les zones tactiles ne sont pas actionnables : fermeture du clavier,
51
+ // voile de modale, blocage de propagation. Les sortir de l'arbre
52
+ // d'accessibilité est la bonne réponse, pas leur inventer un rôle.
53
+ const accessible = findAttribute('accessible');
54
+ const isExcludedFromTree =
55
+ accessible &&
56
+ accessible.value &&
57
+ accessible.value.type === 'JSXExpressionContainer' &&
58
+ accessible.value.expression.type === 'Literal' &&
59
+ accessible.value.expression.value === false;
60
+ if (isExcludedFromTree) {
61
+ return;
62
+ }
63
+
64
+ context.report({ node, messageId: 'missingRole', data: { name: node.name.name } });
65
+ },
66
+ };
67
+ },
68
+ },
69
+ },
70
+ };
71
+
72
+ /** @type {import('eslint').Linter.Config} */
73
+ const config = {
74
+ files: ['**/*.{ts,tsx}'],
75
+ ignores: ['**/*.test.{ts,tsx}', '**/*.stories.tsx'],
76
+ plugins: {
77
+ a11y: accessibilityPlugin,
78
+ },
79
+ rules: {
80
+ 'a11y/touchable-needs-role': 'error',
81
+ },
82
+ };
83
+
84
+ module.exports = config;
@@ -0,0 +1,20 @@
1
+ // `max-lines` repère un fichier qui porte plusieurs responsabilités. Un catalogue de données
2
+ // n'en porte qu'une : il est dense, pas complexe. Le découper pour passer sous la borne
3
+ // gonflerait le nombre de fichiers sans rien découpler, ce qui est proscrit ici.
4
+ //
5
+ // L'exemption est énumérée et non déduite d'un motif large comme `**/constants/**` : une
6
+ // exemption qui ne peut pas s'élargir toute seule est une exemption qu'on peut laisser vivre.
7
+ // Les deux catalogues de tokens sont nommés un par un ; les stories et les jeux de démonstration
8
+ // sont déjà exclus de la même façon par `.jscpd.json` et par les `collectCoverageFrom` de Jest,
9
+ // et cette règle les aligne sur les deux autres dispositifs plutôt que de diverger.
10
+ //
11
+ // Les autres exigences continuent de s'appliquer à ces fichiers : seule la taille est levée.
12
+ /** @type {import('eslint').Linter.Config} */
13
+ const config = {
14
+ files: ['**/*.stories.tsx', '**/*.demo.*', '**/src/constants/Color.ts', '**/src/constants/Palette.ts'],
15
+ rules: {
16
+ 'max-lines': 'off',
17
+ },
18
+ };
19
+
20
+ module.exports = config;
@@ -0,0 +1,26 @@
1
+ // `no-require-imports` interdit `require()` parce qu'il échappe au graphe de modules : le
2
+ // bundler ne peut ni le suivre, ni le secouer. Deux endroits en dépendent justement pour cette
3
+ // raison, et l'exemption est énumérée fichier par fichier, comme celle des catalogues : une
4
+ // exemption qui ne peut pas s'élargir toute seule est une exemption qu'on peut laisser vivre.
5
+ //
6
+ // - `useAppUpdateCheck` sonde `expo-in-app-updates`, qui est optionnel. Un `import` statique
7
+ // lèverait au chargement du module dans les applications qui ne l'installent pas.
8
+ // - `MarkdownDescription` charge `react-markdown` et `remark-gfm` après son early return
9
+ // `Platform.OS !== 'web'`. Un `import` statique embarquerait toute la chaîne `remark` /
10
+ // `micromark` dans le bundle natif, qui ne s'en sert jamais.
11
+ //
12
+ // La sortie par le haut serait `import()`, mais elle rend les deux chargements asynchrones et
13
+ // change la signature des composants : c'est une refonte, pas un nettoyage. À rouvrir si l'un
14
+ // des deux appelants devient asynchrone pour une autre raison.
15
+ /** @type {import('eslint').Linter.Config} */
16
+ const config = {
17
+ files: [
18
+ '**/src/core/AppUpdateProvider/useAppUpdateCheck.ts',
19
+ '**/src/core/MarkdownDescription/MarkdownDescription.tsx',
20
+ ],
21
+ rules: {
22
+ '@typescript-eslint/no-require-imports': 'off',
23
+ },
24
+ };
25
+
26
+ module.exports = config;
@@ -0,0 +1,29 @@
1
+ // Garde-fou de complexité, posé au niveau visé et non au niveau constaté. La dette existante
2
+ // a été gelée le temps de l'absorber, dans le `eslint-suppressions.json` de chaque workspace.
3
+ // Ces quatre fichiers sont maintenant vides et le gel est fermé : `scripts/check-suppressions.mjs`
4
+ // fait échouer la CI dès qu'un d'eux retient une offense. Une violation se corrige donc, elle ne
5
+ // se gèle plus, et un seuil jugé mal calibré se rediscute ici, pour tout le monde a la fois.
6
+ //
7
+ // Les lignes blanches et les commentaires sont exclus des comptages : une fonction ne
8
+ // devient pas complexe parce qu'elle est documentée.
9
+ /** @type {import('eslint').Linter.Config} */
10
+ const config = {
11
+ files: ['**/*.{ts,tsx}'],
12
+ rules: {
13
+ // 10 est le seuil SonarQube par défaut : au-delà, le nombre de chemins d'exécution
14
+ // d'une fonction dépasse ce qu'un test unitaire couvre raisonnablement.
15
+ complexity: ['error', { max: 10 }],
16
+ // Au-delà de trois niveaux d'imbrication, le lecteur ne tient plus les conditions
17
+ // actives en tête. Le remède est l'early return ou l'extraction, jamais l'accolade.
18
+ 'max-depth': ['error', { max: 2 }],
19
+ // Quatre paramètres positionnels, c'est déjà un objet qui s'ignore. Le seuil est
20
+ // atteint aujourd'hui sans dette à geler.
21
+ 'max-params': ['error', { max: 3 }],
22
+ // Une fonction qui ne tient pas sur un écran fait plusieurs choses.
23
+ 'max-lines-per-function': ['error', { max: 60, skipBlankLines: true, skipComments: true }],
24
+ // Même logique au niveau du fichier : au-delà, il porte plusieurs responsabilités.
25
+ 'max-lines': ['error', { max: 250, skipBlankLines: true, skipComments: true }],
26
+ },
27
+ };
28
+
29
+ module.exports = config;
@@ -0,0 +1,22 @@
1
+ // Les fichiers de configuration d'outils (`jest.config.js`, `metro.config.js`…) sont chargés par
2
+ // Node en CommonJS, pas par le bundler. Sans cette déclaration, `no-undef` signale `__dirname`,
3
+ // `module` et `require` comme des variables inconnues, et chaque fichier de configuration doit
4
+ // alors porter un `eslint-disable`. C'était la configuration d'ESLint qui était fausse, pas le
5
+ // code, et ces directives ont disparu d'elles-mêmes une fois la déclaration posée.
6
+ /** @type {import('eslint').Linter.Config} */
7
+ const config = {
8
+ files: ['**/*.config.js', '**/*.config.cjs'],
9
+ languageOptions: {
10
+ sourceType: 'commonjs',
11
+ globals: {
12
+ __dirname: 'readonly',
13
+ __filename: 'readonly',
14
+ module: 'writable',
15
+ exports: 'writable',
16
+ require: 'readonly',
17
+ process: 'readonly',
18
+ },
19
+ },
20
+ };
21
+
22
+ module.exports = config;
@@ -0,0 +1,25 @@
1
+ const eslintComments = require('@eslint-community/eslint-plugin-eslint-comments');
2
+
3
+ // Une règle ne se désactive plus à la ligne : elle se discute dans cette configuration, pour tout
4
+ // le monde à la fois. C'est le pendant de la fermeture du gel - une violation se corrige, ou bien
5
+ // la borne se rediscute ; elle ne se contourne pas en silence dans un coin du code. Sans cette
6
+ // règle, chaque exigence resserrée pouvait être annulée localement par la ligne qu'elle gênait,
7
+ // et l'ensemble du dispositif n'aurait été qu'une suggestion.
8
+ //
9
+ // Les huit directives que portait le dépôt ont été levées avant de poser la règle, et deux
10
+ // d'entre elles masquaient des défauts réels : un gestionnaire figé au premier rendu dans
11
+ // `useDepotFichier`, et deux fichiers de configuration que `no-undef` signalait à tort faute de
12
+ // globales CommonJS déclarées.
13
+ //
14
+ // `eslint-enable` et `eslint-env` restent admis : le premier est sans objet en l'absence de
15
+ // `eslint-disable`, le second ne désactive aucune règle.
16
+ /** @type {import('eslint').Linter.Config} */
17
+ const config = {
18
+ files: ['**/*.{ts,tsx,js,jsx,mjs,cjs}'],
19
+ plugins: { 'eslint-comments': eslintComments },
20
+ rules: {
21
+ 'eslint-comments/no-use': ['error', { allow: ['eslint-enable', 'eslint-env'] }],
22
+ },
23
+ };
24
+
25
+ module.exports = config;
@@ -0,0 +1,28 @@
1
+ const sonarjs = require('eslint-plugin-sonarjs');
2
+
3
+ // Les règles natives d'ESLint mesurent la complexité cyclomatique et la taille : elles ne
4
+ // voient ni la duplication ni la charge de lecture réelle. Ce sous-ensemble de SonarJS
5
+ // complète les deux angles manquants. Il est volontairement restreint : le preset
6
+ // `recommended` du plugin porte des centaines de règles de style qui recouvrent déjà
7
+ // eslint-config-expo et n'ont rien à voir avec la qualité structurelle.
8
+ //
9
+ // Même régime que les règles natives : seuils au niveau visé, et gel fermé (voir
10
+ // `rules/complexity.js`).
11
+ /** @type {import('eslint').Linter.Config} */
12
+ const config = {
13
+ files: ['**/*.{ts,tsx}'],
14
+ plugins: { sonarjs },
15
+ rules: {
16
+ // La complexité cognitive compte ce qui coûte au lecteur, pas les chemins d'exécution :
17
+ // l'imbrication pèse, une suite de ternaires plats ne pèse pas. Sur du JSX elle décrit
18
+ // mieux la réalité que la complexité cyclomatique, qui punit le rendu conditionnel.
19
+ 'sonarjs/cognitive-complexity': ['error', 10],
20
+ // L'équivalent du Flay : deux fonctions identiques, deux branches identiques.
21
+ 'sonarjs/no-identical-functions': 'error',
22
+ 'sonarjs/no-duplicated-branches': 'error',
23
+ 'sonarjs/no-all-duplicated-branches': 'error',
24
+ 'sonarjs/no-identical-expressions': 'error',
25
+ },
26
+ };
27
+
28
+ module.exports = config;
package/rules/tests.js ADDED
@@ -0,0 +1,23 @@
1
+ // Les mocks et les fichiers d'amorçage de Jest s'exécutent dans le harnais de test, qui leur
2
+ // fournit `jest`, `describe`, `expect` et leurs voisins. ESLint ne le sait pas : sans cette
3
+ // déclaration, `no-undef` les signale tous, et chaque fichier doit ouvrir sur un `/* global jest */`
4
+ // - une directive ESLint comme une autre, qui contourne la configuration au lieu de la corriger.
5
+ /** @type {import('eslint').Linter.Config} */
6
+ const config = {
7
+ files: ['**/__tests__/**/*.{js,ts,tsx}', '**/*.{test,spec}.{js,ts,tsx}', '**/jest.setup.{js,ts}'],
8
+ languageOptions: {
9
+ globals: {
10
+ jest: 'readonly',
11
+ describe: 'readonly',
12
+ it: 'readonly',
13
+ test: 'readonly',
14
+ expect: 'readonly',
15
+ beforeAll: 'readonly',
16
+ beforeEach: 'readonly',
17
+ afterAll: 'readonly',
18
+ afterEach: 'readonly',
19
+ },
20
+ },
21
+ };
22
+
23
+ module.exports = config;
@@ -0,0 +1,41 @@
1
+ // `@ts-ignore` et `@ts-nocheck` éteignent le compilateur sans laisser de trace de ce qui est
2
+ // éteint : l'erreur masquée peut changer de nature sans que rien ne le signale. La règle les
3
+ // interdit et n'admet `@ts-expect-error` qu'accompagné d'une description, parce que celui-ci
4
+ // échoue dès que l'erreur attendue disparaît - il se périme tout seul, contrairement aux deux
5
+ // autres. Le seul `@ts-ignore` du dépôt masquait justement une déclaration devenue morte : React
6
+ // 19 a déplacé le namespace `JSX` vers `React.JSX`, et personne ne l'avait vu.
7
+ //
8
+ // `no-explicit-any` ferme la même porte par un autre côté : un `any` éteint le compilateur sur
9
+ // une valeur au lieu d'une ligne, et il se propage à tout ce qui la touche. Les 39 qu'avait
10
+ // `components` ont été retirés sans exemption ; la règle empêche le premier suivant.
11
+ //
12
+ // `consistent-type-assertions` ferme la dernière porte : un `as` affirme un type que le
13
+ // compilateur n'a pas prouvé. Les casts du dépôt ont été remplacés par des types qui disent vrai ;
14
+ // seuls restent ceux que TypeScript ne sait pas exprimer autrement, chacun isolé dans un fichier
15
+ // qui ne fait que ça et qui explique pourquoi. Ils sont exemptés ici, nommément, et nulle part
16
+ // ailleurs.
17
+ const castsAssumes = [
18
+ 'src/helpers/mapValues.ts',
19
+ 'src/helpers/cssVarTypography.ts',
20
+ 'src/helpers/deepMerge.ts',
21
+ 'src/core/styleNatif/versStyleNatif.ts',
22
+ 'src/core/styleTamagui/versStyleTamagui.ts',
23
+ ];
24
+
25
+ /** @type {import('eslint').Linter.Config[]} */
26
+ const config = [
27
+ {
28
+ files: ['**/*.{ts,tsx}'],
29
+ rules: {
30
+ '@typescript-eslint/ban-ts-comment': [
31
+ 'error',
32
+ { 'ts-expect-error': 'allow-with-description', 'ts-ignore': true, 'ts-nocheck': true, 'ts-check': false },
33
+ ],
34
+ '@typescript-eslint/no-explicit-any': 'error',
35
+ '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }],
36
+ },
37
+ },
38
+ { files: castsAssumes, rules: { '@typescript-eslint/consistent-type-assertions': 'off' } },
39
+ ];
40
+
41
+ module.exports = config;