@starci/eslint-canon-fe 9.16.5 → 9.16.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.16.7 - 2026-11-08
4
+
5
+ - Fixed: the test-scope Sonar way block (the app config and the plain-source config) selects the specs with positive selectors (`SPEC_FILES`, `apps/*/src/**/*.{spec,e2e-spec}.{ts,tsx}` and the package twin) instead of `ignores: ['**/*', '!**/*.spec...']`. The harness lint scope (`canonScope` of `@starci/hfs`) refuses a negated selector, so every app's `starci app check --gate lint` ended in `ESLint tool error: eslint source scope is unavailable ... unsupported canon ignores selectors` and linted nothing. The block selects the same files as before and carries the name `starci-fe/sonar-way-tests`, by which `scopeToPackages` narrows it to the specs of the package subpaths.
6
+
7
+ ## 9.16.6 - 2026-11-08
8
+
9
+ - Added (R235 `SCAN_SMELL`, the first Sonar way scan of a real app against local lint): the lint now reports what the server reported. `starci-fe/no-duplicate-import` (S3863, fixable) replaces the core `no-duplicate-imports` that spared a separate type import: a value and a type from one module are one `import { value, type Type }`, and `--fix` merges them. `starci-fe/no-nested-conditional` (S3358) now follows the server: a conditional inside a branch, a test, a call argument, a template or a cast is nested, and only an array, object, function or JSX expression starts a new reading. `starci-fe/prefer-string-raw` (S7780) also refuses a `String.raw` template that holds no backslash (fixable). `starci-fe/no-bidirectional-character` (text:S6389) refuses a raw bidirectional control character in any file. Front end: `no-useless-fragment` (S6749, fixable when the one child is an element), `no-nested-component` (S6478), `no-children-prop` (S6748; its message says a connected block passes the slot under another prop name to the pure component, FE_CONNECTED_BLOCK_RENDER_PAIR and FE_WORLD_OWNER_RENDER_BOUNDARY), `use-state-destructured` (S6754), `prefer-tag-over-role` (S6819).
10
+ - Added: the Sonar way block maps S7739 `unicorn/no-thenable`, S7746 `unicorn/no-useless-promise-resolve-reject` (fixable), S6606 `@typescript-eslint/prefer-nullish-coalescing`, S7781 `unicorn/prefer-string-replace-all` (fixable), S7718 `unicorn/catch-error-name` (fixable), S7727 `unicorn/no-array-callback-reference`, S6671 `prefer-promise-reject-errors`, S6325 `prefer-regex-literals`, S1186 `@typescript-eslint/no-empty-function`, S878 `no-sequences`, S6535 `no-useless-escape`, S7753 `unicorn/prefer-array-index-of` (fixable), S7737 `unicorn/no-object-as-default-parameter`, S7755 `unicorn/prefer-at` (fixable), S7787 `unicorn/require-module-specifiers`, S7766 `unicorn/prefer-math-min-max` (fixable), S6661 `prefer-object-spread` (fixable) and S4123 `@typescript-eslint/await-thenable`, all at error. A catch parameter is `error` (`error_` where `error` is already declared in scope; `cause` stays allowed, the scan accepts it and a back end's wrapped failures are named so).
11
+ - Added: the specs of the product source run the test-scoped Sonar way rules (the sonarjs rules whose Sonar scope is Tests, among them S5906, S8754 and S9073) in a fourth block, because the scan reads `sonar.tests` with them; the product source block still leaves the specs out.
12
+
3
13
  ## 9.16.5 - 2026-11-08
4
14
 
5
15
  - Fixed: the `@starci/hfs` peer is `~6.2.0`, the range of the hfs the canon pins name, so an app on hfs 6.2 installs with `npm ci`.
@@ -12,6 +12,8 @@ export interface SonarPlugins {
12
12
  readonly sonarjs: ESLint.Plugin;
13
13
  readonly unicorn: ESLint.Plugin;
14
14
  }
15
+ /** The name of the test-scope Sonar way block, which the package scoping narrows to the specs of the package subpaths. */
16
+ export declare const TEST_SCOPE_BLOCK = "starci-fe/sonar-way-tests";
15
17
  /** A disable directive is a weakening of lint, and one that suppresses nothing is dead weight. */
16
18
  export declare const linterOptions: Readonly<{
17
19
  noInlineConfig: true;
@@ -19,6 +21,8 @@ export declare const linterOptions: Readonly<{
19
21
  }>;
20
22
  /** The product source of a front end: every app's `src/` and every workspace package's `src/` (slots fe.* and repo.packages). */
21
23
  export declare const SOURCE_FILES: readonly string[];
24
+ /** The specs of that source, the positive `files` of the block that judges the specs alone (no negated selector: the lint scope of the harness proves a scope from plain globs only). */
25
+ export declare const SPEC_FILES: readonly string[];
22
26
  /** Build output, installed packages, generated wire types and the harness's report directory are never linted. */
23
27
  export declare const IGNORED: readonly string[];
24
28
  /**
@@ -89,6 +93,7 @@ export declare const buildPlainSourceConfig: ({ root, plugin, hooks, reactHooks,
89
93
  }) => Linter.Config[];
90
94
  /** The front-end files of a root workspace package: its workbench (drawn with Grammar) and its public face, and every `.tsx` file of the package source wherever it sits (a `src/client/` renderer is drawn too: FE-PACKAGES-2); the React-free workbench contract stays back end. */
91
95
  export declare const PACKAGE_SOURCE_FILES: readonly string[];
96
+ export declare const PACKAGE_SPEC_FILES: readonly string[];
92
97
  /** What a package folder never lints: installed packages, build output, generated wire types. */
93
98
  export declare const PACKAGE_IGNORED: readonly string[];
94
99
  /**
@@ -12,13 +12,17 @@
12
12
  */
13
13
  import tsParser from "@typescript-eslint/parser";
14
14
  import { OUTSIDE_THE_SCAN_FILES } from "#runtime/scripts/lib/sonar-syntax-rules.mjs";
15
- import { sonarWayRules } from "#runtime/scripts/lib/sonar-way.mjs";
15
+ import { sonarWayRules, sonarWayTestRules } from "#runtime/scripts/lib/sonar-way.mjs";
16
16
  import { eslintPlugin } from "./eslint-boundary.js";
17
17
  import { isHfsView } from "./hfs.js";
18
+ /** The name of the test-scope Sonar way block, which the package scoping narrows to the specs of the package subpaths. */
19
+ export const TEST_SCOPE_BLOCK = "starci-fe/sonar-way-tests";
18
20
  /** A disable directive is a weakening of lint, and one that suppresses nothing is dead weight. */
19
21
  export const linterOptions = Object.freeze({ noInlineConfig: true, reportUnusedDisableDirectives: "error" });
20
22
  /** The product source of a front end: every app's `src/` and every workspace package's `src/` (slots fe.* and repo.packages). */
21
23
  export const SOURCE_FILES = Object.freeze(["apps/*/src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"]);
24
+ /** The specs of that source, the positive `files` of the block that judges the specs alone (no negated selector: the lint scope of the harness proves a scope from plain globs only). */
25
+ export const SPEC_FILES = Object.freeze(["apps/*/src/**/*.{spec,e2e-spec}.{ts,tsx}", "packages/*/src/**/*.{spec,e2e-spec}.{ts,tsx}"]);
22
26
  /** Build output, installed packages, generated wire types and the harness's report directory are never linted. */
23
27
  export const IGNORED = Object.freeze(["**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/__generated__/**", ".starci/**"]);
24
28
  /**
@@ -89,6 +93,8 @@ export const buildFeConfig = ({ hfs, plugin, source, reactHooks, tsPlugin, sonar
89
93
  },
90
94
  // Sonar way: what the scan raises is refused while the code is written; a spec is outside the scan, so outside this block.
91
95
  { files: [...SOURCE_FILES], ignores: [...OUTSIDE_THE_SCAN_FILES], plugins: { sonarjs: sonar.sonarjs, unicorn: sonar.unicorn }, rules: sonarWayRules(sonar.sonarjs, "fe").rules },
96
+ // Sonar way, test scope: the scan reads the specs with the rules of the tests (sonar.tests), so the specs of the source are judged by those and by no other.
97
+ { name: TEST_SCOPE_BLOCK, files: [...SPEC_FILES], plugins: { sonarjs: sonar.sonarjs }, rules: sonarWayTestRules(sonar.sonarjs) },
92
98
  ];
93
99
  };
94
100
  /**
@@ -104,12 +110,14 @@ export const SLOT_FREE_RULES = Object.freeze([
104
110
  "no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "no-copy-in-call", "no-alert", "no-failure-collapse",
105
111
  "outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
106
112
  "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params", "no-line-separator-literal",
113
+ "no-bidirectional-character", "no-duplicate-import", "no-useless-fragment", "no-nested-component", "no-children-prop", "use-state-destructured", "prefer-tag-over-role",
107
114
  "no-list-panel-padding", "no-hand-built-panes", "no-table-in-card", "no-heading-above-card", "no-selectable-tag-group", "no-fill-command-in-header", "no-app-surface-paint", "divider-full-bleed", "control-height", "selection-fill-no-check", "no-grammar-shim",
108
115
  "no-swr-default-array", "no-foreign-slot-status", "no-validating-as-loading", "no-placeholder-object", "no-hand-skeleton", "explicit-export-surface", "directive-first", "renderer-presentation-only",
109
116
  "button-intent-required", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
110
117
  ]);
111
118
  /** The source of a plain front-end tree: its `src/`. */
112
119
  export const PLAIN_SOURCE_FILES = Object.freeze(["src/**/*.{ts,tsx}"]);
120
+ const PLAIN_SPEC_FILES = Object.freeze(["src/**/*.{spec,e2e-spec}.{ts,tsx}"]);
113
121
  /**
114
122
  * The flat config of a TypeScript front-end tree that is no HFS app: the same fences as an app (typed linting, no inline directive, every rule at
115
123
  * `error`) over the rules that need no slot view. It takes the root of the tree and nothing else.
@@ -146,10 +154,12 @@ export const buildPlainSourceConfig = ({ root, plugin, hooks, reactHooks, tsPlug
146
154
  rules: { ...canonRules, ...hooks, ...BORROWED },
147
155
  },
148
156
  { files: [...PLAIN_SOURCE_FILES], ignores: [...OUTSIDE_THE_SCAN_FILES], plugins: { sonarjs: sonar.sonarjs, unicorn: sonar.unicorn }, rules: sonarWayRules(sonar.sonarjs, "fe").rules },
157
+ { name: TEST_SCOPE_BLOCK, files: [...PLAIN_SPEC_FILES], plugins: { sonarjs: sonar.sonarjs }, rules: sonarWayTestRules(sonar.sonarjs) },
149
158
  ];
150
159
  };
151
160
  /** The front-end files of a root workspace package: its workbench (drawn with Grammar) and its public face, and every `.tsx` file of the package source wherever it sits (a `src/client/` renderer is drawn too: FE-PACKAGES-2); the React-free workbench contract stays back end. */
152
161
  export const PACKAGE_SOURCE_FILES = Object.freeze(["packages/*/src/workbench/**/*.{ts,tsx}", "packages/*/src/public/**/*.{ts,tsx}", "packages/*/src/**/*.tsx"]);
162
+ export const PACKAGE_SPEC_FILES = Object.freeze(["packages/*/src/workbench/**/*.{spec,e2e-spec}.{ts,tsx}", "packages/*/src/public/**/*.{spec,e2e-spec}.{ts,tsx}", "packages/*/src/**/*.{spec,e2e-spec}.tsx"]);
153
163
  const PACKAGE_CONTRACT = Object.freeze(["packages/*/src/workbench/contract/**"]);
154
164
  /** What a package folder never lints: installed packages, build output, generated wire types. */
155
165
  export const PACKAGE_IGNORED = Object.freeze(["**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/__generated__/**"]);
@@ -164,5 +174,5 @@ export const PACKAGE_IGNORED = Object.freeze(["**/node_modules/**", "**/.next/**
164
174
  export const scopeToPackages = (config) => config.map((block) => {
165
175
  if (block.files === undefined)
166
176
  return { ...block, ignores: [...PACKAGE_IGNORED] };
167
- return { ...block, files: [...PACKAGE_SOURCE_FILES], ignores: [...(block.ignores ?? []), ...PACKAGE_CONTRACT] };
177
+ return { ...block, files: [...(block.name === TEST_SCOPE_BLOCK ? PACKAGE_SPEC_FILES : PACKAGE_SOURCE_FILES)], ignores: [...(block.ignores ?? []), ...PACKAGE_CONTRACT] };
168
178
  });
@@ -4,7 +4,7 @@
4
4
  * Each rule is the syntax-tree form of one Sonar rule, shared with the back-end canon (scripts/lib/sonar-syntax-rules.mjs,
5
5
  * bundled under `runtime/`): `no-await-in-loop` (S9382), `prefer-code-point` (S7758),
6
6
  * `prefer-string-raw` (S7780), `no-nested-conditional` (S3358), `no-void-operator` (S3735), `no-unused-import` (S1128),
7
- * `prefer-export-from` (S7763), `no-unused-prop-types` (S6767) and `media-has-caption` (S4084). The rest of Sonar way is the factory's
7
+ * `prefer-export-from` (S7763), `no-unused-prop-types` (S6767), `media-has-caption` (S4084), `no-duplicate-import` (S3863, fixable), `no-useless-fragment` (S6749, fixable), `no-nested-component` (S6478), `no-children-prop` (S6748), `use-state-destructured` (S6754), `prefer-tag-over-role` (S6819) and `no-bidirectional-character` (text:S6389). The rest of Sonar way is the factory's
8
8
  * Sonar way block (scripts/lib/sonar-way.mjs). The three Sonar rules that need the type checker, S1874 (a deprecated
9
9
  * API), S6551 (an object turned into text) and S7503 (an `async` function that never awaits), are the typescript-eslint rules
10
10
  * `no-deprecated`, `no-base-to-string` and `require-await` the factory turns on.
@@ -17,7 +17,7 @@ const syntaxRule = (name) => {
17
17
  throw new TypeError(`the runtime Sonar syntax rules publish no ${name}`);
18
18
  return rule;
19
19
  };
20
- const NAMES = ["no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import", "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params", "no-line-separator-literal"];
20
+ const NAMES = ["no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import", "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params", "no-line-separator-literal", "no-bidirectional-character", "no-duplicate-import", "no-useless-fragment", "no-nested-component", "no-children-prop", "use-state-destructured", "prefer-tag-over-role"];
21
21
  /** The rules this law contributes to the plugin. */
22
22
  export const rules = Object.fromEntries(NAMES.map((name) => [name, syntaxRule(name)]));
23
23
  /** Starts at error: a repository's fix lane clears the debt before the rule ships. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.16.5",
3
+ "version": "9.16.7",
4
4
  "description": "Universal front-end lint laws for TypeScript and Next.js, independent of the selected Grammar family.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -85,5 +85,7 @@
85
85
  "eslint": "^9.18.0",
86
86
  "eslint-plugin-react-hooks": "^7.1.1",
87
87
  "typescript": "^6.0.3"
88
- }
88
+ },
89
+ "gitHead": "1ac3e3ed36e167ca7dbed5cad593ca8033106f44",
90
+ "starciSource": "7fed82137872a789ce9a69b64d07ddaca1758b3f96b2b0a8ef36935de076f28a"
89
91
  }
@@ -74,23 +74,47 @@ const KEEPS_ITS_ESCAPES = new Set(['ImportDeclaration', 'ExportAllDeclaration',
74
74
  const isMatcherValue = (node) => (node.parent?.type === 'Property' && node.parent.value === node && node.parent.key?.name === 'matcher')
75
75
  || (node.parent?.type === 'ArrayExpression' && isMatcherValue(node.parent));
76
76
 
77
- /** S7780: a string whose only escapes are backslashes reads better as `String.raw`. */
77
+ /** Whether the tag is `String.raw`. */
78
+ const isStringRaw = (tag) => tag.type === 'MemberExpression' && !tag.computed && tag.object.type === 'Identifier' && tag.object.name === 'String' && tag.property.type === 'Identifier' && tag.property.name === 'raw';
79
+
80
+ /** S7780: a string whose only escapes are backslashes reads better as `String.raw`; a `String.raw` template with no backslash in it needs no tag. */
78
81
  const preferStringRaw = {
79
- meta: { type: 'suggestion', schema: [], messages: { raw: 'S7780: write this string with `String.raw` so its backslashes are not escaped.' } },
82
+ meta: {
83
+ type: 'suggestion',
84
+ fixable: 'code',
85
+ schema: [],
86
+ messages: {
87
+ raw: 'S7780: write this string with `String.raw` so its backslashes are not escaped.',
88
+ needless: 'S7780: this `String.raw` template holds no backslash; write a plain template literal.',
89
+ },
90
+ },
80
91
  create: (context) => ({
81
92
  Literal: (node) => {
82
93
  if (KEEPS_ITS_ESCAPES.has(node.parent?.type) || node.parent?.type === 'ExpressionStatement' || isMatcherValue(node)) return;
83
94
  if (onlyBackslashEscapes(node)) context.report({ node, messageId: 'raw' });
84
95
  },
96
+ TaggedTemplateExpression: (node) => {
97
+ if (!isStringRaw(node.tag) || node.quasi.quasis.some((quasi) => quasi.value.raw.includes('\\'))) return;
98
+ context.report({ node, messageId: 'needless', fix: (fixer) => fixer.removeRange([node.tag.range[0], node.quasi.range[0]]) });
99
+ },
85
100
  }),
86
101
  };
87
102
 
88
- /** S3358: a conditional expression inside another conditional expression is read inside out; name the inner one. */
103
+ /** What starts a new reading context between two conditional expressions: the inner one is then a value of its own, not a branch of the outer. */
104
+ const BREAKS_NESTING = new Set(['ArrayExpression', 'ObjectExpression', 'FunctionExpression', 'ArrowFunctionExpression', 'JSXExpressionContainer']);
105
+
106
+ /** S3358: a conditional expression anywhere inside another one (a branch, a test, a call argument, a template, a cast), up to an array, object, function or JSX expression, is read inside out; name the inner one. */
89
107
  const noNestedConditional = {
90
108
  meta: { type: 'suggestion', schema: [], messages: { nested: 'S3358: extract this nested conditional expression into its own statement or a named function.' } },
91
109
  create: (context) => ({
92
110
  ConditionalExpression: (node) => {
93
- if (node.parent?.type === 'ConditionalExpression') context.report({ node, messageId: 'nested' });
111
+ for (let parent = node.parent; parent; parent = parent.parent) {
112
+ if (parent.type === 'ConditionalExpression') {
113
+ context.report({ node, messageId: 'nested' });
114
+ return;
115
+ }
116
+ if (BREAKS_NESTING.has(parent.type)) return;
117
+ }
94
118
  },
95
119
  }),
96
120
  };
@@ -264,6 +288,233 @@ const noLineSeparatorLiteral = {
264
288
  }),
265
289
  };
266
290
 
291
+ /** The line width of @starci/prettier-config: a merged import longer than this is laid out one specifier a line. */
292
+ const PRINT_WIDTH = 120;
293
+ const TRAILING_BLANK = /[ \t]*\r?\n?/y;
294
+
295
+ /** S3863: one module is imported once; a value and a type from the same module are one `import { value, type Type }`. */
296
+ const noDuplicateImport = {
297
+ meta: { type: 'suggestion', fixable: 'code', schema: [], messages: { duplicate: 'S3863: \'{{ source }}\' is imported more than once. Merge the imports into one: `import { value, type Type } from \'{{ source }}\'`.' } },
298
+ create: (context) => {
299
+ const { sourceCode } = context;
300
+ const groups = new Map();
301
+ /** The specifier list of one merged import, or null when the group cannot be merged without changing what it binds. */
302
+ const mergedText = (group) => {
303
+ const defaults = group.filter((declaration) => declaration.specifiers.some((specifier) => specifier.type === 'ImportDefaultSpecifier'));
304
+ const mergeable = group.every((declaration) => (declaration.attributes?.length ?? 0) === 0 && declaration.specifiers.every((specifier) => specifier.type !== 'ImportNamespaceSpecifier'))
305
+ && defaults.length <= 1 && defaults.every((declaration) => declaration.importKind !== 'type');
306
+ if (!mergeable) return null;
307
+ const allType = group.every((declaration) => declaration.importKind === 'type');
308
+ const names = new Set();
309
+ for (const declaration of group) {
310
+ for (const specifier of declaration.specifiers) {
311
+ if (specifier.type !== 'ImportSpecifier') continue;
312
+ const text = sourceCode.getText(specifier);
313
+ names.add(declaration.importKind === 'type' && !allType && specifier.importKind !== 'type' ? `type ${text}` : text);
314
+ }
315
+ }
316
+ const head = defaults[0]?.specifiers.find((specifier) => specifier.type === 'ImportDefaultSpecifier');
317
+ const parts = [head ? sourceCode.getText(head) : null, names.size > 0 ? `{ ${[...names].join(', ')} }` : null].filter(Boolean);
318
+ const semicolon = sourceCode.getText(group[0]).endsWith(';') ? ';' : '';
319
+ const kind = allType ? 'type ' : '';
320
+ const tail = ` from ${sourceCode.getText(group[0].source)}${semicolon}`;
321
+ const single = `import ${kind}${parts.join(', ')}${tail}`;
322
+ if (single.length <= PRINT_WIDTH || names.size === 0) return single;
323
+ // as Prettier lays out an import that does not fit one line: one specifier a line, a trailing comma
324
+ const lines = [...names].map((name) => ` ${name},`).join('\n');
325
+ return `import ${kind}${head ? `${sourceCode.getText(head)}, ` : ''}{\n${lines}\n}${tail}`;
326
+ };
327
+ const removal = (declaration) => {
328
+ TRAILING_BLANK.lastIndex = declaration.range[1];
329
+ const tail = TRAILING_BLANK.exec(sourceCode.text);
330
+ return [declaration.range[0], declaration.range[1] + (tail ? tail[0].length : 0)];
331
+ };
332
+ return {
333
+ ImportDeclaration: (node) => {
334
+ if (node.specifiers.length === 0) return;
335
+ const key = `${String(node.source.value)}\u0000${(node.attributes ?? []).map((attribute) => sourceCode.getText(attribute)).join(',')}`;
336
+ groups.set(key, [...(groups.get(key) ?? []), node]);
337
+ },
338
+ 'Program:exit': () => {
339
+ for (const group of groups.values()) {
340
+ if (group.length < 2) continue;
341
+ const merged = mergedText(group);
342
+ group.slice(1).forEach((declaration, index) => {
343
+ const fix = index === 0 && merged !== null
344
+ ? (fixer) => [fixer.replaceText(group[0], merged), ...group.slice(1).map((later) => fixer.removeRange(removal(later)))]
345
+ : undefined;
346
+ context.report({ node: declaration, messageId: 'duplicate', data: { source: String(declaration.source.value) }, fix });
347
+ });
348
+ }
349
+ },
350
+ };
351
+ },
352
+ };
353
+
354
+ /** The first non-whitespace-only children of a fragment or element: JSX trims a text of blanks that holds a line break. */
355
+ const significantChildren = (children) => children.filter((child) => !(child.type === 'JSXText' && /^\s*$/u.test(child.value) && /[\r\n]/u.test(child.value)) && !(child.type === 'JSXExpressionContainer' && child.expression.type === 'JSXEmptyExpression'));
356
+
357
+ /** Whether the opening element is a fragment: `<>` (a JSXFragment) is handled apart, this is `<Fragment>` or `<React.Fragment>`. */
358
+ const isFragmentElement = (opening) => (opening.name.type === 'JSXIdentifier' && opening.name.name === 'Fragment')
359
+ || (opening.name.type === 'JSXMemberExpression' && opening.name.property.name === 'Fragment' && opening.name.object.type === 'JSXIdentifier');
360
+
361
+ /** S6749: a fragment that wraps nothing or one child is removed; the child stands for it. */
362
+ const noUselessFragment = {
363
+ meta: {
364
+ type: 'suggestion',
365
+ fixable: 'code',
366
+ schema: [],
367
+ messages: {
368
+ empty: 'S6749: remove this empty fragment.',
369
+ single: 'S6749: this fragment wraps one child; remove the fragment and keep the child.',
370
+ },
371
+ },
372
+ create: (context) => {
373
+ const { sourceCode } = context;
374
+ const check = (node, children) => {
375
+ const parentIsJsx = node.parent?.type === 'JSXElement' || node.parent?.type === 'JSXFragment';
376
+ const kept = significantChildren(children);
377
+ if (kept.length === 0) {
378
+ context.report({ node, messageId: 'empty' });
379
+ return;
380
+ }
381
+ if (kept.length !== 1) return;
382
+ const [only] = kept;
383
+ if (only.type === 'JSXText' && !parentIsJsx) return;
384
+ const fix = only.type === 'JSXElement' || only.type === 'JSXFragment' ? (fixer) => fixer.replaceText(node, sourceCode.getText(only)) : undefined;
385
+ context.report({ node, messageId: 'single', fix });
386
+ };
387
+ return {
388
+ JSXFragment: (node) => check(node, node.children),
389
+ JSXElement: (node) => {
390
+ if (isFragmentElement(node.openingElement) && node.openingElement.attributes.length === 0) check(node, node.children);
391
+ },
392
+ };
393
+ },
394
+ };
395
+
396
+ const isUppercaseName = (name) => typeof name === 'string' && /^[A-Z]/u.test(name);
397
+
398
+ /** The name a function is bound to: its own id, or the variable that holds it directly or through `memo(...)` / `forwardRef(...)`. */
399
+ function functionName(node) {
400
+ if (node.id?.name) return node.id.name;
401
+ let holder = node.parent;
402
+ while (holder?.type === 'CallExpression') holder = holder.parent;
403
+ return holder?.type === 'VariableDeclarator' && holder.id.type === 'Identifier' ? holder.id.name : null;
404
+ }
405
+
406
+ /** S6478: a component declared inside another component is a new type on every render and remounts its subtree; declare it at module level. */
407
+ const noNestedComponent = {
408
+ meta: { type: 'problem', schema: [], messages: { nested: 'S6478: the component `{{ name }}` is declared inside the component `{{ outer }}`, so it is a new component on every render and its state is lost. Move it to module level and pass what it reads as props.' } },
409
+ create: (context) => {
410
+ const frames = [];
411
+ const enter = (node) => frames.push({ node, name: functionName(node), jsx: false, nested: [] });
412
+ const markJsx = () => frames.forEach((frame) => { frame.jsx = true; });
413
+ const exit = () => {
414
+ const frame = frames.pop();
415
+ if (isUppercaseName(frame.name) && frame.jsx) {
416
+ const outer = [...frames].reverse().find((candidate) => isUppercaseName(candidate.name));
417
+ outer?.nested.push(frame);
418
+ }
419
+ if (frame.jsx) for (const inner of frame.nested) context.report({ node: inner.node.id ?? inner.node.parent ?? inner.node, messageId: 'nested', data: { name: inner.name, outer: frame.name } });
420
+ };
421
+ return { FunctionDeclaration: enter, FunctionExpression: enter, ArrowFunctionExpression: enter, 'FunctionDeclaration:exit': exit, 'FunctionExpression:exit': exit, 'ArrowFunctionExpression:exit': exit, JSXElement: markJsx, JSXFragment: markJsx };
422
+ },
423
+ };
424
+
425
+ /** S6748: children go between the tags. Where they cannot (a connected block that renders a pure component), the slot has a name of its own. */
426
+ const noChildrenProp = {
427
+ meta: {
428
+ type: 'problem',
429
+ schema: [],
430
+ messages: {
431
+ children: 'S6748: do not pass `children` as a prop; nest the content between the tags. In a connected block (`blocks/*/index.tsx`, FE_CONNECTED_BLOCK_RENDER_PAIR, FE_WORLD_OWNER_RENDER_BOUNDARY) nesting is not available: pass the slot under a prop of another name (`content`, `footer`, ...) to the pure component.',
432
+ },
433
+ },
434
+ create: (context) => ({
435
+ JSXAttribute: (node) => {
436
+ if (node.name.type === 'JSXIdentifier' && node.name.name === 'children') context.report({ node, messageId: 'children' });
437
+ },
438
+ CallExpression: (node) => {
439
+ const callee = node.callee;
440
+ const creates = (callee.type === 'Identifier' && callee.name === 'createElement') || (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier' && callee.property.name === 'createElement');
441
+ const props = node.arguments[1];
442
+ if (!creates || props?.type !== 'ObjectExpression') return;
443
+ for (const property of props.properties) {
444
+ if (property.type === 'Property' && !property.computed && (property.key.name === 'children' || property.key.value === 'children')) context.report({ node: property, messageId: 'children' });
445
+ }
446
+ },
447
+ }),
448
+ };
449
+
450
+ /** S6754: `useState` is destructured into a value and its setter, named after each other. */
451
+ const useStateDestructured = {
452
+ meta: {
453
+ type: 'suggestion',
454
+ schema: [],
455
+ messages: {
456
+ pair: 'S6754: destructure the `useState` result into a value and its setter: `const [value, setValue] = useState(...)`.',
457
+ symmetric: 'S6754: name the setter after the value: `[{{ value }}, set{{ Value }}]`.',
458
+ },
459
+ },
460
+ create: (context) => ({
461
+ CallExpression: (node) => {
462
+ const callee = node.callee;
463
+ const isUseState = (callee.type === 'Identifier' && callee.name === 'useState') || (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier' && callee.property.name === 'useState');
464
+ if (!isUseState || node.parent?.type !== 'VariableDeclarator' || node.parent.init !== node) return;
465
+ const id = node.parent.id;
466
+ if (id.type !== 'ArrayPattern' || id.elements.length !== 2) {
467
+ context.report({ node: id, messageId: 'pair' });
468
+ return;
469
+ }
470
+ const [value, setter] = id.elements;
471
+ if (value?.type !== 'Identifier' || setter?.type !== 'Identifier') return;
472
+ const Value = value.name.charAt(0).toUpperCase() + value.name.slice(1);
473
+ if (setter.name !== `set${Value}`) context.report({ node: setter, messageId: 'symmetric', data: { value: value.name, Value } });
474
+ },
475
+ }),
476
+ };
477
+
478
+ /** The native element each ARIA role stands for: a host element with the role and another tag is written as the native one (S6819). A role with no native element, or an ambiguous one (`img` on an `svg`, `group`), is not listed. */
479
+ const NATIVE_FOR_ROLE = Object.freeze({
480
+ status: ['output'], navigation: ['nav'], main: ['main'], article: ['article'], complementary: ['aside'], contentinfo: ['footer'], banner: ['header'],
481
+ form: ['form'], button: ['button'], dialog: ['dialog'], figure: ['figure'], heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], link: ['a'],
482
+ list: ['ul', 'ol', 'menu'], listitem: ['li'], progressbar: ['progress'], region: ['section'], separator: ['hr'], table: ['table'], row: ['tr'], cell: ['td'],
483
+ columnheader: ['th'], rowheader: ['th'], checkbox: ['input'], radio: ['input'], slider: ['input'], spinbutton: ['input'], searchbox: ['input'],
484
+ textbox: ['input', 'textarea'], option: ['option'], combobox: ['select', 'input'], term: ['dt'], definition: ['dd'],
485
+ });
486
+
487
+ /** S6819: a native element carries its role; a host element given a `role` that another native element has is that element. */
488
+ const preferTagOverRole = {
489
+ meta: { type: 'suggestion', schema: [], messages: { tag: 'S6819: use the native <{{ tag }}> element instead of role="{{ role }}" on <{{ name }}>.' } },
490
+ create: (context) => ({
491
+ JSXOpeningElement: (node) => {
492
+ if (node.name.type !== 'JSXIdentifier' || !/^[a-z]/u.test(node.name.name)) return;
493
+ const attribute = attributeOf(node, 'role');
494
+ const literal = attribute?.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : attribute?.value;
495
+ const role = literal?.type === 'Literal' ? literal.value : undefined;
496
+ const tags = typeof role === 'string' ? NATIVE_FOR_ROLE[role.trim().split(/\s+/u)[0]] : undefined;
497
+ if (tags === undefined || tags.includes(node.name.name)) return;
498
+ context.report({ node: attribute, messageId: 'tag', data: { tag: tags[0], role: role.trim(), name: node.name.name } });
499
+ },
500
+ }),
501
+ };
502
+
503
+ /** The bidirectional control characters of text:S6389: they reorder the text a reader sees against the text the compiler reads. */
504
+ const BIDI_CONTROLS = /[‪-‮⁦-⁩]/gu;
505
+ /** text:S6389 (Trojan Source): a raw embedding, override, isolate or pop control character anywhere in a file, specs included, is refused. */
506
+ const noBidirectionalCharacter = {
507
+ meta: { type: 'problem', schema: [], messages: { raw: 'A bidirectional control character ({{ code }}) reorders how this line reads. Write the escape `\\u{{{ hex }}}` inside a string, template or regular expression, or delete the character anywhere else.' } },
508
+ create: (context) => ({
509
+ Program: () => {
510
+ for (const found of context.sourceCode.text.matchAll(BIDI_CONTROLS)) {
511
+ const hex = found[0].codePointAt(0).toString(16).toUpperCase();
512
+ context.report({ loc: context.sourceCode.getLocFromIndex(found.index), messageId: 'raw', data: { code: `U+${hex}`, hex } });
513
+ }
514
+ },
515
+ }),
516
+ };
517
+
267
518
  /** The files the example scans leave out: a unit spec and an end-to-end spec (the regex, and the flat-config globs of the same set). */
268
519
  const OUTSIDE_THE_SCAN = /\.(?:e2e-)?spec\.[cm]?[jt]sx?$/;
269
520
  export const OUTSIDE_THE_SCAN_FILES = Object.freeze(['**/*.spec.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '**/*.e2e-spec.{js,mjs,cjs,ts,mts,cts,jsx,tsx}']);
@@ -271,7 +522,7 @@ export const OUTSIDE_THE_SCAN_FILES = Object.freeze(['**/*.spec.{js,mjs,cjs,ts,m
271
522
  /** The rule judging only the files the scan judges. */
272
523
  const scanned = (rule) => ({ ...rule, create: (context) => (OUTSIDE_THE_SCAN.test(context.filename) ? {} : rule.create(context)) });
273
524
 
274
- /** The rules both canons publish, by published name. */
525
+ /** The rules both canons publish, by published name (the React ones, from `no-useless-fragment` to `prefer-tag-over-role`, only the front end names). */
275
526
  export const SONAR_SYNTAX_RULES = Object.freeze({
276
527
  'no-await-in-loop': scanned(noAwaitInLoop),
277
528
  'prefer-code-point': scanned(preferCodePoint),
@@ -284,4 +535,11 @@ export const SONAR_SYNTAX_RULES = Object.freeze({
284
535
  'media-has-caption': scanned(mediaHasCaption),
285
536
  'max-params': scanned(maxParams),
286
537
  'no-line-separator-literal': noLineSeparatorLiteral,
538
+ 'no-bidirectional-character': noBidirectionalCharacter,
539
+ 'no-duplicate-import': scanned(noDuplicateImport),
540
+ 'no-useless-fragment': scanned(noUselessFragment),
541
+ 'no-nested-component': scanned(noNestedComponent),
542
+ 'no-children-prop': scanned(noChildrenProp),
543
+ 'use-state-destructured': scanned(useStateDestructured),
544
+ 'prefer-tag-over-role': scanned(preferTagOverRole),
287
545
  });
@@ -34,12 +34,33 @@ export const SONAR_WAY_NOT_APPLICABLE = Object.freeze({
34
34
 
35
35
  /**
36
36
  * The Sonar way rules SonarSource implements on an ESLint rule outside eslint-plugin-sonarjs: `{ sonar, rule, options? }`. The canon syntax
37
- * rules carry S107 (`max-params`, which spares the constructor of an injectable class as the server does) and S4084 (`media-has-caption`,
38
- * front end); S6582 is typescript-eslint's `prefer-optional-chain`, which needs the type information the canons' typed block provides.
37
+ * rules (sonar-syntax-rules.mjs) carry S107 (`max-params`, which spares the constructor of an injectable class as the server does), S3863
38
+ * (`no-duplicate-import`: a value and a type from one module are one import, and `--fix` merges them), S4084 (`media-has-caption`), S6478
39
+ * (`no-nested-component`), S6748 (`no-children-prop`), S6749 (`no-useless-fragment`), S6754 (`use-state-destructured`) and S6819
40
+ * (`prefer-tag-over-role`), the last five front end; S6582 is typescript-eslint's `prefer-optional-chain`, which needs the type information
41
+ * the canons' typed block provides, and so do S6606 and S4123. S7718 keeps `cause` as a catch parameter's name beside `error` (and `error_`
42
+ * where `error` is already declared): the scan accepts it, and the back end's wrapped failures are named so.
39
43
  */
40
44
  export const SONAR_WAY_EXTRA = Object.freeze([
41
- { sonar: 'S3863', rule: 'no-duplicate-imports', options: [{ allowSeparateTypeImports: true }] },
42
45
  { sonar: 'S6582', rule: '@typescript-eslint/prefer-optional-chain' },
46
+ { sonar: 'S6606', rule: '@typescript-eslint/prefer-nullish-coalescing' },
47
+ { sonar: 'S4123', rule: '@typescript-eslint/await-thenable' },
48
+ { sonar: 'S1186', rule: '@typescript-eslint/no-empty-function' },
49
+ { sonar: 'S6671', rule: 'prefer-promise-reject-errors', options: [{ allowEmptyReject: false }] },
50
+ { sonar: 'S6325', rule: 'prefer-regex-literals' },
51
+ { sonar: 'S878', rule: 'no-sequences', options: [{ allowInParentheses: false }] },
52
+ { sonar: 'S6535', rule: 'no-useless-escape' },
53
+ { sonar: 'S6661', rule: 'prefer-object-spread' },
54
+ { sonar: 'S7718', rule: 'unicorn/catch-error-name', options: [{ name: 'error', ignore: [/^cause$/u] }] },
55
+ { sonar: 'S7727', rule: 'unicorn/no-array-callback-reference' },
56
+ { sonar: 'S7737', rule: 'unicorn/no-object-as-default-parameter' },
57
+ { sonar: 'S7739', rule: 'unicorn/no-thenable' },
58
+ { sonar: 'S7746', rule: 'unicorn/no-useless-promise-resolve-reject' },
59
+ { sonar: 'S7753', rule: 'unicorn/prefer-array-index-of' },
60
+ { sonar: 'S7755', rule: 'unicorn/prefer-at' },
61
+ { sonar: 'S7766', rule: 'unicorn/prefer-math-min-max' },
62
+ { sonar: 'S7781', rule: 'unicorn/prefer-string-replace-all' },
63
+ { sonar: 'S7787', rule: 'unicorn/require-module-specifiers' },
43
64
  { sonar: 'S4138', rule: 'unicorn/no-for-loop' },
44
65
  { sonar: 'S7721', rule: 'unicorn/consistent-function-scoping' },
45
66
  { sonar: 'S7732', rule: 'unicorn/no-instanceof-builtins' },
@@ -56,6 +77,36 @@ export const SONAR_WAY_EXTRA = Object.freeze([
56
77
  { sonar: 'S7784', rule: 'unicorn/prefer-structured-clone' },
57
78
  ]);
58
79
 
80
+ /**
81
+ * The sonarjs rules whose Sonar scope is Tests (a `scope = 'Tests'` in the rule's generated meta, asserted in sonar-way.spec.ts): the scan
82
+ * runs them over the files `sonar.tests` names and not over the product source, so the canons run them over the specs and nowhere else
83
+ * is added. S9073 is outside sonarjs' own recommended set and inside the profile.
84
+ */
85
+ export const SONAR_WAY_TEST_RULES = Object.freeze([
86
+ 'no-skipped-tests', 'no-empty-test-file', 'assertions-in-tests', 'no-fixed-wait-in-tests', 'no-incomplete-assertions', 'inverted-assertion-arguments',
87
+ 'no-incompatible-assertion-types', 'no-same-argument-assert', 'prefer-specific-assertions', 'no-trivial-assertions', 'test-check-exception',
88
+ 'stable-tests', 'parameterized-tests', 'no-code-after-done', 'disabled-timeout', 'chai-determinate-assertion', 'no-exclusive-tests',
89
+ 'no-duplicate-test-title', 'async-test-assertions', 'no-empty-test-title', 'hooks-before-test-cases', 'no-forced-browser-interaction',
90
+ 'assertions-in-test-cases', 'synchronous-suite-callback', 'no-debug-commands-in-ui-tests', 'no-interpolation-in-inline-snapshots',
91
+ 'explicit-test-skip', 'no-empty-parameterized-test-dataset', 'testing-library-query-assertion', 'synchronous-exception-assertions',
92
+ 'composite-assertions', 'no-duplicate-parameterized-test-case', 'testing-library-prefer-query-by-disappearance', 'prefer-cypress-should',
93
+ 'vitest-mock-at-module-scope', 'no-networkidle-wait',
94
+ ]);
95
+
96
+ /**
97
+ * The flat-config rule levels of the test-scoped Sonar way rules, for the spec files the product source block leaves out.
98
+ *
99
+ * @param {object} sonarjs - The eslint-plugin-sonarjs plugin.
100
+ */
101
+ export function sonarWayTestRules(sonarjs) {
102
+ const rules = {};
103
+ for (const name of SONAR_WAY_TEST_RULES) {
104
+ const declared = sonarjs.configs.recommended.rules[`sonarjs/${name}`];
105
+ rules[`sonarjs/${name}`] = ['error', ...(Array.isArray(declared) ? declared.slice(1) : [])];
106
+ }
107
+ return rules;
108
+ }
109
+
59
110
  /** The ESM-only Sonar way rules: S7785 prefers top-level await (see SONAR_WAY_NOT_APPLICABLE). */
60
111
  const SONAR_WAY_ESM = Object.freeze([{ sonar: 'S7785', rule: 'unicorn/prefer-top-level-await' }]);
61
112