@scayle/eslint-config-storefront 5.1.0 → 5.2.0

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,27 @@
1
1
  # @aboutyou/eslint-config-sfc
2
2
 
3
+ ## 5.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Adds a new `require-lazy-promise-in-view-props` rule to the V3 entry point (`storefrontV3()`), enabled at `error`. It flags a promise created inside a controller's `getViewProps()` that isn't wrapped in `lazyPromise` and isn't awaited or returned immediately, catching both wasted fetches on partial reloads and unhandled promise rejections.
8
+
9
+ This rule needs real type information to work, and it reads that from the same `resolver.tsconfigPath` option consumers already set for the import resolver. Because ESLint flat config merges type information across every config block matching a file, every consumer that sets `resolver.tsconfigPath` now gets real type-aware linting from `eslint-plugin-sonarjs` across their whole TypeScript codebase, not just from this new rule. `eslint-plugin-sonarjs`'s type-aware rules (`sonarjs/deprecation`, `sonarjs/different-types-comparison`, `sonarjs/no-redundant-optional`, and others) previously ran without type information and silently found nothing; they now report real findings. Existing consumers should expect to see and fix new `sonarjs` errors the first time they lint after upgrading.
10
+
11
+ ### Patch Changes
12
+
13
+ - Added dependency `@typescript-eslint/utils@^8.70.0`
14
+ - Added dependency `ts-api-utils@^2.1.2`
15
+ - Updated dependency `eslint-plugin-unicorn@^74.0.0` to `eslint-plugin-unicorn@^76.0.0`
16
+ - Updated dependency `typescript-eslint@^8.67.0` to `typescript-eslint@^8.70.0`
17
+ - Speed up the V3 flat config. `@intlify/vue-i18n/no-unused-keys` now excludes test and story files from its scan, since they don't introduce translation key usages beyond what source files already have. `sonarjs/aws-restricted-ip-admin-access` and `better-tailwindcss/no-deprecated-classes` are turned off: the former only fires on AWS CDK code, the latter only on Tailwind v3 class names, and the `/v3` entry point is Tailwind v4 only.
18
+
19
+ ## 5.1.1
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependency `eslint-plugin-unicorn@^73.0.0` to `eslint-plugin-unicorn@^74.0.0`
24
+
3
25
  ## 5.1.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -11,6 +11,28 @@ Should you require support for `eslint@8`, please use `@scayle/eslint-config-sto
11
11
 
12
12
  ---
13
13
 
14
+ ## Entry points
15
+
16
+ The package exposes three entry points, all built on the same shared rule set. Pick the one
17
+ that matches your stack:
18
+
19
+ | Entry point | Export | For | Adds on top of the shared rules |
20
+ | ----------- | --------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | Default | `@scayle/eslint-config-storefront` | Framework-agnostic TypeScript/Node.js packages | JavaScript, TypeScript, JSDoc, and import rules |
22
+ | Nuxt | `@scayle/eslint-config-storefront/nuxt` | Nuxt projects — this is what **Storefront Application V2** uses, since V2 is a Nuxt 4 application | Nothing beyond the shared rules; `@nuxt/eslint` already supplies JavaScript, TypeScript, JSDoc, and import rules |
23
+ | V3 | `@scayle/eslint-config-storefront/v3` | The **Storefront Application V3** stack (Hono + Inertia.js + Vue 3, _not_ Nuxt) | Everything the default entry point adds, plus Vue language wiring, Vue rules, accessibility rules, `vue-i18n` linting, `@scayle/vue-composable` rules, Storybook file conventions, Tailwind CSS class validation, a TypeScript-aware import resolver, and the `require-lazy-promise-in-view-props` rule |
24
+
25
+ There is no `/v2` entry point: Storefront Application V2 is a Nuxt application, so it uses the
26
+ `/nuxt` entry point directly, the same as any other Nuxt project. "V2" and "V3" describe the two
27
+ Storefront Application stacks this monorepo ships, not two separate ESLint configs — V2 maps to
28
+ `/nuxt`, V3 maps to `/v3`.
29
+
30
+ Internally, `/nuxt` and `/v3` both build on the same base config, so a rule fixed or added to the
31
+ shared set (JavaScript, Node.js, JSON/JSONC, test files, SonarJS, Promise usage, modernization,
32
+ regex safety, comments hygiene) applies to every entry point without duplicating configuration.
33
+ Only `/v3` and the default entry point add TypeScript, JSDoc, and import rules directly — `/nuxt`
34
+ relies on `@nuxt/eslint` for those instead.
35
+
14
36
  ## Install
15
37
 
16
38
  Add the `@scayle/eslint-config-storefront` manually to your project:
@@ -28,7 +50,7 @@ import storefront from '@scayle/eslint-config-storefront'
28
50
  export default storefront()
29
51
  ```
30
52
 
31
- ### Nuxt projects
53
+ ### Nuxt projects (Storefront Application V2)
32
54
 
33
55
  Nuxt projects already get JavaScript, TypeScript, JSDoc, and import rules from
34
56
  [`@nuxt/eslint`](https://eslint.nuxt.com/). Use the `/nuxt` entry point instead, which
@@ -43,12 +65,15 @@ import withNuxt from './.nuxt/eslint.config.mjs'
43
65
  export default withNuxt(storefrontNuxt())
44
66
  ```
45
67
 
68
+ This is the entry point Storefront Application V2's own `eslint.config.mjs` uses.
69
+
46
70
  ### Storefront Application V3 projects
47
71
 
48
72
  The Storefront Application V3 stack (Hono + Inertia.js + Vue 3, not Nuxt) should use the
49
73
  `/v3` entry point. It builds on the default config and adds Vue language wiring, Vue rules,
50
74
  accessibility rules, `vue-i18n` linting, `@scayle/vue-composable` rules, Storybook file
51
- conventions, Tailwind CSS class validation, and a TypeScript-aware import resolver:
75
+ conventions, Tailwind CSS class validation, a TypeScript-aware import resolver, and the
76
+ `require-lazy-promise-in-view-props` rule:
52
77
 
53
78
  ```js
54
79
  // eslint.config.mjs
@@ -70,14 +95,76 @@ validate Tailwind classes or i18n keys without a real path. Optional options:
70
95
  - `typescript.tsconfigPath`: absolute path to a `tsconfig.json`. Enables type-aware
71
96
  TypeScript rules and points the import resolver at that file instead of
72
97
  `<root>/tsconfig.json`
73
- - `resolver.tsconfigPath`: absolute path to a `tsconfig.json` for the import resolver only,
74
- independent of `typescript.tsconfigPath`. Use this instead when you want the resolver
75
- fix below without also switching on type-aware TypeScript rules
98
+ - `resolver.tsconfigPath`: absolute path to a `tsconfig.json` for the import resolver and,
99
+ with the same `?? typescript.tsconfigPath` fallback, for type-checking the
100
+ `require-lazy-promise-in-view-props` rule below — independent of `typescript.tsconfigPath`
101
+ in the sense that setting it does not switch on `@typescript-eslint/*` type-checked rules
102
+ (`recommendedTypeChecked`) generally. Use this instead of `typescript.tsconfigPath` when you
103
+ want the resolver fix and the lazy-promise rule without also switching on
104
+ `@typescript-eslint/*` type-checked rules project-wide. When neither option resolves a
105
+ tsconfig path, `require-lazy-promise-in-view-props` is omitted entirely rather than
106
+ registered without type information. Setting it DOES have one other real effect: because
107
+ ESLint flat config merges type information across every config block matching a file, and
108
+ the lazy-promise rule's own block matches every `.ts`/`.tsx`/`.mts`/`.cts` file (not just
109
+ files with a `getViewProps` method), `eslint-plugin-sonarjs`'s type-aware rules gain real
110
+ type information project-wide too — they silently report nothing without a type checker, so
111
+ expect new `sonarjs` findings the first time you set this option, not just wherever the
112
+ lazy-promise rule itself looks
76
113
  - `i18n.messageSyntaxVersion`: `vue-i18n` message syntax version, defaults to `^11.2.8`
77
114
  - `i18n.unusedKeyIgnores`: regex-string patterns for translation keys used dynamically in code
78
115
  - `vue.undefComponentIgnores`: custom element (web component) tag names to exempt from `vue/no-undef-components`
79
116
  - `vueComposable.fallthroughAttrsIgnores`: path fragments to exempt from `require-explicit-fallthrough-attrs` (e.g. router-mounted page/layout roots that never receive fallthrough attributes)
80
117
 
118
+ This is the entry point Storefront Application V3's own `eslint.config.mjs` uses.
119
+
120
+ #### Vue rules and accessibility
121
+
122
+ `/v3` wires the Vue parser (including `<script setup lang="ts">`) via `vue-eslint-parser`, applies
123
+ `eslint-plugin-vue`'s essential, strongly-recommended, and recommended presets, and layers a
124
+ handful of Storefront-specific overrides on top (e.g. `vue/custom-event-name-casing`,
125
+ `vue/block-order`, styling rules disabled where they conflict with this project's Prettier
126
+ config). `vue-eslint-parser` doesn't reuse the type-aware `@typescript-eslint/no-unused-vars`
127
+ config from the TypeScript rules, since that one only scopes to `.ts`/`.tsx`/`.mts`/`.cts` files,
128
+ so `.vue` files get their own registration of the same rule.
129
+
130
+ Vue accessibility rules (`eslint-plugin-vuejs-accessibility`'s recommended preset) are wired but
131
+ every rule ships `off` by default. That baseline reflects that a11y coverage is opt-in until a
132
+ consuming project runs its own audit and turns rules on deliberately — it is not a statement that
133
+ accessibility doesn't matter.
134
+
135
+ #### `vue-i18n` linting
136
+
137
+ Validates message syntax, catches missing/unused/duplicate translation keys, and — only in
138
+ CI (`process.env.CI` set) — enforces that every locale file carries the same keys as the others
139
+ at `error` (a local `warn` lets you author a key in one locale before running the translation
140
+ step). Requires `i18n.localeDir` to be set; there is no default, since the plugin can't validate
141
+ anything without a real path to your locale files.
142
+
143
+ #### `@scayle/vue-composable` rules
144
+
145
+ Enables all five rules from the sibling
146
+ [`@scayle/eslint-plugin-vue-composable`](../eslint-plugin-vue-composable) package at `error`:
147
+ `no-composable-after-await`, `no-lifecycle-after-await`, `no-watch-after-await`,
148
+ `no-computed-after-await` (composables/lifecycle hooks/watchers/computed refs called after an
149
+ `await` inside a composable bind to the wrong instance), and
150
+ `require-explicit-fallthrough-attrs` (multi-root components must declare `inheritAttrs: false`,
151
+ since Vue can't infer where fallthrough attributes should land). See that package's own README
152
+ for the full rationale and examples per rule.
153
+
154
+ #### Storybook conventions
155
+
156
+ Applies `eslint-plugin-storybook`'s recommended preset to files matching `*.stories.*` /
157
+ `*.story.*`. No configuration needed.
158
+
159
+ #### Tailwind CSS class validation
160
+
161
+ Validates Tailwind utility classes via `eslint-plugin-better-tailwindcss`'s recommended preset,
162
+ against the CSS entry point you provide in `tailwind.entryPoint`. Class-order and
163
+ line-wrapping rules are disabled, since this project's Prettier Tailwind plugin already handles
164
+ sorting and would otherwise conflict with the ESLint plugin's own ordering opinion.
165
+ `no-deprecated-classes` is disabled too: the `/v3` entry point targets Tailwind v4, so this
166
+ rule's v3 class-name findings don't apply.
167
+
81
168
  #### Import resolution
82
169
 
83
170
  `/v3` wires `eslint-import-resolver-typescript`, so `import-x` rules resolve `tsconfig.json`
@@ -103,6 +190,64 @@ directory instead. Set `resolver.tsconfigPath` to an absolute path (for example,
103
190
  `new URL('./tsconfig.json', import.meta.url).pathname` in your `eslint.config.mjs`) if your
104
191
  editor's linting needs to match the command-line result.
105
192
 
193
+ #### `require-lazy-promise-in-view-props`
194
+
195
+ Enforces the `lazyPromise` convention in Inertia controllers: a promise created inside a
196
+ controller's `getViewProps()` method must be wrapped in the SDK's `lazyPromise(() => ...)`
197
+ helper, unless it's awaited or returned in the same statement it's created in. This closes
198
+ two related problems that a promise created eagerly in `getViewProps()` can cause: a
199
+ partial reload that excludes every prop consuming that promise still triggers the fetch
200
+ (wasted work), and an uncaught rejection on such a promise can surface as an unhandled
201
+ promise rejection. The rule reports at `error` and offers a suggestion (not an autofix,
202
+ since call sites also need to change from a bare reference to an accessor call, `x` → `x()`)
203
+ that wraps the flagged expression.
204
+
205
+ ```ts
206
+ // Before — flagged
207
+ const productsPromise = fetchProducts(params)
208
+ return {
209
+ products: async () => (await productsPromise).items,
210
+ pagination: async () => (await productsPromise).pagination,
211
+ }
212
+
213
+ // After — the suggested fix, plus updating both call sites
214
+ const getProducts = lazyPromise(() => fetchProducts(params))
215
+ return {
216
+ products: async () => (await getProducts()).items,
217
+ pagination: async () => (await getProducts()).pagination,
218
+ }
219
+ ```
220
+
221
+ The rule is type-aware: it detects any thenable-typed local (not just a literal `Promise`
222
+ name match), so a `PromiseLike`-shaped or branded return type is caught too. It only checks
223
+ locals declared directly in `getViewProps()`'s own body — a promise created and consumed
224
+ entirely inside a nested function (a prop callback, a nested class's method) is not flagged,
225
+ since that promise is already effectively lazy: the enclosing function only runs when it's
226
+ invoked. A correctly-wrapped `lazyPromise(() => ...)` call itself returns a function, not a
227
+ thenable, so it never trips the check; passing `lazyPromise` an already-invoked promise
228
+ instead of a factory (`lazyPromise(fetchThing())` instead of
229
+ `lazyPromise(() => fetchThing())`) is a type error the TypeScript compiler catches on its
230
+ own — the wrong argument shape doesn't satisfy `lazyPromise`'s signature — so the rule
231
+ doesn't need its own check for that case.
232
+
233
+ Known limitations: the rule does not verify that the `lazyPromise` identifier it sees
234
+ resolves to the SDK's own export — a same-named local declaration would also satisfy it. It
235
+ likewise doesn't verify that a consuming prop callback actually chains the accessor's result
236
+ into its own return value; a callback that calls the accessor without awaiting or returning
237
+ it could still produce an unhandled rejection the rule won't catch. It also doesn't track
238
+ references across sibling declarators in the same body, so two independent promises
239
+ consumed together via `Promise.all([a, b])` are each flagged individually even though
240
+ neither needs `lazyPromise` there.
241
+
242
+ Requires `resolver.tsconfigPath` or `typescript.tsconfigPath` to be set — see
243
+ [Import resolution](#import-resolution) above. When neither is set, the rule is omitted
244
+ entirely from the config rather than registered without type information, which would
245
+ otherwise crash ESLint on the first `.ts` file it lints. Note that `resolver.tsconfigPath`
246
+ is passed to `parserOptions.projectService.defaultProject`, which is a fallback for files
247
+ not covered by an auto-discovered `tsconfig.json` — and which typescript-eslint hard-fails
248
+ on after 8 unmatched files by design. If your project's own `tsconfig.json` doesn't cover
249
+ every `.ts` file this rule lints, you may see an ESLint crash rather than a lint error.
250
+
106
251
  ### Add script for package.json
107
252
 
108
253
  For example:
@@ -149,6 +294,69 @@ Add the following settings to your `.vscode/settings.json`:
149
294
  }
150
295
  ```
151
296
 
297
+ ## What the shared rules cover
298
+
299
+ Every entry point (default, `/nuxt`, `/v3`) includes the following rule domains. They live in
300
+ `config/rules.mjs`, `config/javascript.mjs`, `config/typescript.mjs`, `config/unicorn.mjs`,
301
+ `config/test.mjs`, and `config/ignores.mjs` in this package's source, one factory function per
302
+ domain:
303
+
304
+ - **Comments hygiene** (`eslint-comments/*`): no aggregating/duplicate/unlimited
305
+ `eslint-disable` comments, no unused `eslint-enable`.
306
+ - **Node.js** (`node/*`): deprecated API usage, callback error-handling conventions,
307
+ `process`/`Buffer` globals preferred over the bare identifiers.
308
+ - **JSON/JSONC** (`jsonc/*`): structural validity plus formatting rules for `.json`,
309
+ `.json5`, and `.jsonc` files.
310
+ - **Test files** (`vitest/*`, `no-only-tests/*`): `it`/`describe` conventions, no
311
+ duplicate test titles, no accidental `.only` left in committed code (`error` in CI,
312
+ `warn` locally so you can use `.only` while debugging).
313
+ - **SonarJS** (`sonarjs/*`): the plugin's recommended preset, with `no-duplicate-string`
314
+ off, `todo-tag`/`fixme-tag` downgraded to `warn`, and `aws-restricted-ip-admin-access`
315
+ off (it only fires on AWS CDK infrastructure code).
316
+ - **Promise usage** (`promise/*`): a curated subset of `eslint-plugin-promise`'s rules —
317
+ `no-multiple-resolved`, `no-new-statics`, and `no-return-wrap` at `error`, the rest at
318
+ `warn`. `catch-or-return` and `always-return` only inspect a promise used as a bare
319
+ statement, so a project whose promises are all stored in a variable before being awaited
320
+ or returned (as V3's controller code is) can safely turn both `off` in its own
321
+ `eslint.config.mjs` — Storefront Application V3's own config does exactly that.
322
+ (`require-lazy-promise-in-view-props` is what enforces the equivalent convention for V3
323
+ controllers; see above.)
324
+ - **Modernization** (`unicorn/*`, `e18e/*`): a curated subset of `eslint-plugin-unicorn`
325
+ (not its full `recommended` preset, which is hundreds of largely stylistic rules), plus
326
+ the [e18e](https://e18e.dev) community's rule set for modernization, module-replacement,
327
+ and performance findings. Every `e18e/*` rule ships at `warn` — the plugin is still
328
+ pre-1.0 and its rule set hasn't been reviewed against every consuming project yet, so a
329
+ warning surfaces a finding without failing your build. See
330
+ [`e18e/*` rules ship as warnings](#e18e-rules-ship-as-warnings) below for the one rule
331
+ worth knowing about specifically.
332
+ - **Regex safety** (`regexp/*`): `eslint-plugin-regexp`'s recommended preset, including
333
+ ReDoS-prone pattern detection.
334
+
335
+ The default and `/v3` entry points additionally include:
336
+
337
+ - **JavaScript** (`@eslint/js`'s recommended preset plus Storefront-specific additions):
338
+ strict equality, no implicit globals, consistent `const`/`let` usage, and similar
339
+ correctness rules that apply regardless of TypeScript.
340
+ - **TypeScript** (`@typescript-eslint/*`): the plugin's recommended preset, upgraded to
341
+ `recommendedTypeChecked` when a `tsconfigPath` is provided (see
342
+ [Storefront Application V3 projects](#storefront-application-v3-projects) above for how
343
+ V3 supplies one), plus Storefront-specific overrides — consistent type-only imports,
344
+ `interface` over `type` for object shapes, unused-variable detection that allows a
345
+ leading underscore to mark an intentionally-unused binding.
346
+ - **JSDoc** (`jsdoc/*`): validates JSDoc comment structure (parameter names, return
347
+ descriptions, alignment) at `warn`. This package is published and consumed by external
348
+ developers, so exported symbols are expected to carry meaningful documentation.
349
+ - **Import correctness** (`import-x/*`): duplicate imports, mutable exports, self-imports,
350
+ extraneous dependencies not declared in `package.json`, and consistent type-import
351
+ specifier style.
352
+ - **Ignore patterns**: build artifacts, lockfiles, and tooling caches (`node_modules`,
353
+ `dist`, `.output`, coverage reports, and similar) are excluded, on top of whatever your
354
+ project's own `.gitignore` excludes.
355
+
356
+ `/nuxt` omits all five of the above: `@nuxt/eslint` already provides JavaScript, TypeScript,
357
+ JSDoc, and import rules for a Nuxt project, and Nuxt's own tooling manages its build-output
358
+ ignore patterns.
359
+
152
360
  ## Customization
153
361
 
154
362
  ### Default config / `@scayle/eslint-config-storefront`
package/config/rules.mjs CHANGED
@@ -89,6 +89,8 @@ const SONARJS_RULES = {
89
89
  'sonarjs/no-duplicate-string': 'off',
90
90
  'sonarjs/todo-tag': 'warn',
91
91
  'sonarjs/fixme-tag': 'warn',
92
+ // Flags AWS CDK infrastructure code, which none of our projects use.
93
+ 'sonarjs/aws-restricted-ip-admin-access': 'off',
92
94
  }
93
95
 
94
96
  /** @returns {import('eslint').Linter.Config[]} Flat config entries for SonarJS rules. */
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true
8
+ },
9
+ "include": ["file.ts"]
10
+ }
@@ -0,0 +1,127 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils'
2
+ import * as tsutils from 'ts-api-utils'
3
+
4
+ const createRule = ESLintUtils.RuleCreator(
5
+ () =>
6
+ 'https://github.com/scayle/eslint-config-storefront/blob/main/config/v3/requireLazyPromise.mjs',
7
+ )
8
+
9
+ const rule = createRule({
10
+ name: 'require-lazy-promise-in-view-props',
11
+ meta: {
12
+ type: 'problem',
13
+ docs: {
14
+ description:
15
+ 'Require promises created in getViewProps() to be wrapped in lazyPromise unless awaited or returned immediately.',
16
+ },
17
+ schema: [],
18
+ hasSuggestions: true,
19
+ messages: {
20
+ requireLazyPromise:
21
+ 'Wrap this promise in `lazyPromise(() => ...)` so it only runs when a prop actually consumes it. A partial reload that excludes every consuming prop otherwise still triggers this fetch, and an uncaught rejection here can become an unhandled promise rejection.',
22
+ },
23
+ },
24
+ defaultOptions: [],
25
+ create(context) {
26
+ const services = ESLintUtils.getParserServices(context)
27
+ const checker = services.program.getTypeChecker()
28
+
29
+ return {
30
+ VariableDeclarator(node) {
31
+ const init = node.init
32
+ if (init == null) {
33
+ return
34
+ }
35
+
36
+ // Only flag declarators directly inside getViewProps's own body. A
37
+ // promise inside a nested function (a prop callback, a nested method)
38
+ // is already lazy by construction, since that function only runs
39
+ // when invoked — reporting there would be a false positive.
40
+ const owner = context.sourceCode.getScope(node).variableScope.block
41
+ if (
42
+ owner.type !== 'FunctionExpression' ||
43
+ owner.parent.type !== 'MethodDefinition' ||
44
+ owner.parent.key.type !== 'Identifier' ||
45
+ owner.parent.key.name !== 'getViewProps'
46
+ ) {
47
+ return
48
+ }
49
+
50
+ // A correctly-wrapped `lazyPromise(() => ...)` call returns a
51
+ // function, not a thenable, so the type check below already skips
52
+ // it — no separate call-shape check needed.
53
+ const tsNode = services.esTreeNodeToTSNodeMap.get(init)
54
+ if (
55
+ !tsutils.isThenableType(
56
+ checker,
57
+ tsNode,
58
+ checker.getTypeAtLocation(tsNode),
59
+ )
60
+ ) {
61
+ return
62
+ }
63
+
64
+ context.report({
65
+ node: init,
66
+ messageId: 'requireLazyPromise',
67
+ suggest: [
68
+ {
69
+ messageId: 'requireLazyPromise',
70
+ fix: (fixer) =>
71
+ fixer.replaceText(
72
+ init,
73
+ `lazyPromise(() => ${context.sourceCode.getText(init)})`,
74
+ ),
75
+ },
76
+ ],
77
+ })
78
+ },
79
+ }
80
+ },
81
+ })
82
+
83
+ /**
84
+ * Enables the `require-lazy-promise-in-view-props` rule. This rule requires type
85
+ * information, so it is a no-op (returns no config entries) when `tsconfigPath`
86
+ * cannot be resolved — registering the rule without type information would crash
87
+ * ESLint's entire run on the first `.ts` file, rather than degrading gracefully.
88
+ * @param {string} [tsconfigPath] Absolute path to the tsconfig.json used as the
89
+ * default project for files not covered by an auto-discovered tsconfig.
90
+ * @returns {import('eslint').Linter.Config[]} Flat config entries for the require-lazy-promise
91
+ * rule, or an empty array when no tsconfig path is resolvable.
92
+ */
93
+ export function requireLazyPromiseConfig(tsconfigPath) {
94
+ if (!tsconfigPath) {
95
+ return []
96
+ }
97
+
98
+ return [
99
+ {
100
+ name: 'storefront/v3/require-lazy-promise',
101
+ files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
102
+ languageOptions: {
103
+ parserOptions: {
104
+ projectService: { defaultProject: tsconfigPath },
105
+ tsconfigRootDir: process.cwd(),
106
+ },
107
+ },
108
+ plugins: {
109
+ storefront: {
110
+ rules: {
111
+ // @typescript-eslint/utils RuleModuleWithName is compatible at runtime but
112
+ // structurally incompatible with ESLint's Rule.RuleModule; bridge via unknown cast
113
+ 'require-lazy-promise-in-view-props':
114
+ /** @type {import('eslint').Rule.RuleModule} */ (
115
+ /** @type {unknown} */ (rule)
116
+ ),
117
+ },
118
+ },
119
+ },
120
+ rules: {
121
+ 'storefront/require-lazy-promise-in-view-props': 'error',
122
+ },
123
+ },
124
+ ]
125
+ }
126
+
127
+ export default rule
@@ -0,0 +1,170 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { RuleTester } from '@typescript-eslint/rule-tester'
4
+ import { describe, it } from 'vitest'
5
+ import rule from './requireLazyPromise.mjs'
6
+
7
+ RuleTester.describe = describe
8
+ RuleTester.it = it
9
+ // Required RuleTester wiring, not a left-in test filter: this assigns the reference so a test
10
+ // CASE can opt into `only: true`; it does not itself run any test with `.only`.
11
+ RuleTester.itOnly = it.only // eslint-disable-line no-only-tests/no-only-tests
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
+ const fixtureDir = path.join(__dirname, '__fixtures__', 'require-lazy-promise')
15
+
16
+ const ruleTester = new RuleTester({
17
+ languageOptions: {
18
+ parserOptions: {
19
+ project: path.join(fixtureDir, 'tsconfig.json'),
20
+ tsconfigRootDir: fixtureDir,
21
+ },
22
+ },
23
+ })
24
+
25
+ const filename = 'file.ts'
26
+
27
+ ruleTester.run('require-lazy-promise-in-view-props', rule, {
28
+ valid: [
29
+ {
30
+ name: 'lazyPromise-wrapped local',
31
+ filename,
32
+ code: `
33
+ declare function lazyPromise<T>(factory: () => Promise<T>): () => Promise<T>
34
+ declare function fetchThing(): Promise<string>
35
+ class C {
36
+ getViewProps() {
37
+ const getThing = lazyPromise(() => fetchThing())
38
+ return { thing: async () => await getThing() }
39
+ }
40
+ }
41
+ `,
42
+ },
43
+ {
44
+ name: 'promise awaited inline, never stored',
45
+ filename,
46
+ code: `
47
+ declare function fetchThing(): Promise<string>
48
+ class C {
49
+ async getViewProps() {
50
+ return { thing: await fetchThing() }
51
+ }
52
+ }
53
+ `,
54
+ },
55
+ {
56
+ name: 'non-thenable local is ignored',
57
+ filename,
58
+ code: `
59
+ class C {
60
+ getViewProps() {
61
+ const category = { id: 1 }
62
+ return { category }
63
+ }
64
+ }
65
+ `,
66
+ },
67
+ {
68
+ name: 'method not named getViewProps is ignored',
69
+ filename,
70
+ code: `
71
+ declare function fetchThing(): Promise<string>
72
+ class C {
73
+ getOtherProps() {
74
+ const thingPromise = fetchThing()
75
+ return { thing: async () => await thingPromise }
76
+ }
77
+ }
78
+ `,
79
+ },
80
+ {
81
+ name: 'promise created and consumed entirely inside a prop callback is not flagged (already lazy by construction)',
82
+ filename,
83
+ code: `
84
+ declare function fetchThing(): Promise<string>
85
+ class C {
86
+ getViewProps() {
87
+ return {
88
+ thing: async () => {
89
+ const inner = fetchThing()
90
+ return await inner
91
+ },
92
+ }
93
+ }
94
+ }
95
+ `,
96
+ },
97
+ ],
98
+ invalid: [
99
+ {
100
+ name: 'eager promise shared by two props',
101
+ filename,
102
+ code: `
103
+ declare function fetchThing(): Promise<{ a: string; b: string }>
104
+ class C {
105
+ getViewProps() {
106
+ const thingPromise = fetchThing()
107
+ return {
108
+ a: async () => (await thingPromise).a,
109
+ b: async () => (await thingPromise).b,
110
+ }
111
+ }
112
+ }
113
+ `,
114
+ errors: [
115
+ {
116
+ messageId: 'requireLazyPromise',
117
+ suggestions: [
118
+ {
119
+ messageId: 'requireLazyPromise',
120
+ output: `
121
+ declare function fetchThing(): Promise<{ a: string; b: string }>
122
+ class C {
123
+ getViewProps() {
124
+ const thingPromise = lazyPromise(() => fetchThing())
125
+ return {
126
+ a: async () => (await thingPromise).a,
127
+ b: async () => (await thingPromise).b,
128
+ }
129
+ }
130
+ }
131
+ `,
132
+ },
133
+ ],
134
+ },
135
+ ],
136
+ },
137
+ {
138
+ name: 'eager promise with a .catch already attached is still flagged',
139
+ filename,
140
+ code: `
141
+ declare function fetchThing(): Promise<string | undefined>
142
+ class C {
143
+ getViewProps() {
144
+ const thingPromise = fetchThing().catch(() => undefined)
145
+ return { thing: async () => await thingPromise }
146
+ }
147
+ }
148
+ `,
149
+ errors: [
150
+ {
151
+ messageId: 'requireLazyPromise',
152
+ suggestions: [
153
+ {
154
+ messageId: 'requireLazyPromise',
155
+ output: `
156
+ declare function fetchThing(): Promise<string | undefined>
157
+ class C {
158
+ getViewProps() {
159
+ const thingPromise = lazyPromise(() => fetchThing().catch(() => undefined))
160
+ return { thing: async () => await thingPromise }
161
+ }
162
+ }
163
+ `,
164
+ },
165
+ ],
166
+ },
167
+ ],
168
+ },
169
+ ],
170
+ })
@@ -22,6 +22,8 @@ export function tailwindConfig(config) {
22
22
  // The eslint plugin uses a different class order than the prettier plugin.
23
23
  // Disabled to avoid conflicts between the two.
24
24
  'better-tailwindcss/enforce-consistent-class-order': 'off',
25
+ // Flags Tailwind v3 class names. The /v3 entry point targets Tailwind v4 only.
26
+ 'better-tailwindcss/no-deprecated-classes': 'off',
25
27
  },
26
28
  settings: {
27
29
  'better-tailwindcss': { entryPoint },
@@ -35,10 +35,12 @@ export function vueI18nConfig(config) {
35
35
  '@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error',
36
36
  '@intlify/vue-i18n/no-dynamic-keys': 'off',
37
37
  // Performance-intensive: scans all matching files for unused translation keys.
38
+ // Excludes tests and stories, which don't introduce key usages beyond what
39
+ // source files already have, to cut the scanned file count roughly in half.
38
40
  '@intlify/vue-i18n/no-unused-keys': [
39
41
  'error',
40
42
  {
41
- src: './src/{client,server,shared}/**',
43
+ src: './src/{client,server,shared}/**/!(*.test|*.stories).{vue,ts}',
42
44
  extensions: ['.vue', '.ts'],
43
45
  ignores: unusedKeyIgnores,
44
46
  },
package/config/v3.mjs CHANGED
@@ -3,6 +3,7 @@ import { storefront } from './index.mjs'
3
3
  import { vueConfig } from './v3/vue.mjs'
4
4
  import { vueI18nConfig } from './v3/vueI18n.mjs'
5
5
  import { vueComposableConfig } from './v3/vueComposable.mjs'
6
+ import { requireLazyPromiseConfig } from './v3/requireLazyPromise.mjs'
6
7
  import { storybookConfig } from './v3/storybook.mjs'
7
8
  import { tailwindConfig } from './v3/tailwind.mjs'
8
9
  import {
@@ -16,13 +17,22 @@ import {
16
17
  * @property {string} [typescript.tsconfigPath] Enables type-aware TypeScript rules when set.
17
18
  * @property {object} [resolver] Import resolver settings.
18
19
  * @property {string} [resolver.tsconfigPath] Absolute path to the tsconfig.json the import
19
- * resolver reads `paths` aliases from. Independent of `typescript.tsconfigPath`: passing
20
- * an absolute path here does not switch on type-aware TypeScript rules. Omit to fall back
21
- * to `typescript.tsconfigPath`, then to the resolver's own `<cwd>/tsconfig.json` default,
22
- * which only resolves correctly when ESLint's working directory happens to be this project's
23
- * root (true for `pnpm lint`/CI via Turborepo, not guaranteed for an editor's ESLint
24
- * extension). Set this explicitly, as an absolute path anchored to the config file's own
25
- * location, if ESLint might ever run with a different working directory.
20
+ * resolver reads `paths` aliases from. Independent of `typescript.tsconfigPath`: it does not
21
+ * switch on `tseslint.configs.recommendedTypeChecked` or any other `@typescript-eslint/*`
22
+ * type-checked rule set. Omit to fall back to `typescript.tsconfigPath`, then to the
23
+ * resolver's own `<cwd>/tsconfig.json` default, which only resolves correctly when ESLint's
24
+ * working directory happens to be this project's root (true for `pnpm lint`/CI via
25
+ * Turborepo, not guaranteed for an editor's ESLint extension). Set this explicitly, as an
26
+ * absolute path anchored to the config file's own location, if ESLint might ever run with a
27
+ * different working directory. Also used, with the same `?? typescript?.tsconfigPath`
28
+ * fallback, by `requireLazyPromiseConfig` to type-check the `require-lazy-promise-in-view-props`
29
+ * rule; when neither option resolves, that rule is omitted entirely rather than registered
30
+ * without type information. Setting this DOES have one other real effect: the type
31
+ * information `requireLazyPromiseConfig` attaches applies, via ESLint flat config's
32
+ * per-file `languageOptions` merging, to every `.ts`/`.tsx`/`.mts`/`.cts` file in the
33
+ * project (its own block matches `**\/*.ts` broadly, not just files with a `getViewProps`
34
+ * method) — so `eslint-plugin-sonarjs`'s type-aware rules, which silently no-op without a
35
+ * type checker, start reporting real findings project-wide the first time this option is set.
26
36
  * @property {object} tailwind Tailwind CSS settings.
27
37
  * @property {string} tailwind.entryPoint Path to the project's Tailwind CSS entry point.
28
38
  * @property {object} i18n `vue-i18n` linting settings.
@@ -60,6 +70,9 @@ export const storefrontV3 = function (config) {
60
70
  ...vueConfig(vue),
61
71
  ...vueI18nConfig(i18n),
62
72
  ...vueComposableConfig(vueComposable),
73
+ ...requireLazyPromiseConfig(
74
+ resolver?.tsconfigPath ?? typescript?.tsconfigPath,
75
+ ),
63
76
  ...importResolverConfig(resolver?.tsconfigPath ?? typescript?.tsconfigPath),
64
77
  ...importOrderConfig(),
65
78
  )
@@ -125,4 +125,58 @@ describe('storefrontV3', () => {
125
125
  }).toConfigs(),
126
126
  ).resolves.toBeInstanceOf(Array)
127
127
  })
128
+
129
+ it('wires the require-lazy-promise-in-view-props rule when resolver.tsconfigPath is set', async () => {
130
+ const config = storefrontV3({
131
+ tailwind: { entryPoint: './src/client/index.css' },
132
+ i18n: { localeDir: './src/i18n/locales/*.json' },
133
+ resolver: { tsconfigPath: '/absolute/resolver-tsconfig.json' },
134
+ })
135
+
136
+ const resolved = await config.toConfigs()
137
+ const entry = resolved.find(
138
+ (c) => c.name === 'storefront/v3/require-lazy-promise',
139
+ )
140
+ if (!entry) {
141
+ throw new Error('require-lazy-promise entry not found')
142
+ }
143
+ const { rules } = entry
144
+ if (!rules) {
145
+ throw new Error('require-lazy-promise entry has no rules')
146
+ }
147
+
148
+ expect(rules['storefront/require-lazy-promise-in-view-props']).toBe('error')
149
+ })
150
+
151
+ it('wires the require-lazy-promise-in-view-props rule via typescript.tsconfigPath when resolver.tsconfigPath is unset', async () => {
152
+ const config = storefrontV3({
153
+ tailwind: { entryPoint: './src/client/index.css' },
154
+ i18n: { localeDir: './src/i18n/locales/*.json' },
155
+ typescript: { tsconfigPath: '/absolute/typescript-tsconfig.json' },
156
+ })
157
+
158
+ const resolved = await config.toConfigs()
159
+ const entry = resolved.find(
160
+ (c) => c.name === 'storefront/v3/require-lazy-promise',
161
+ )
162
+
163
+ expect(entry).toBeDefined()
164
+ })
165
+
166
+ // Registering the rule without type information would crash ESLint's entire
167
+ // run on the first .ts file, rather than reporting a lint error — omitting
168
+ // the entry is what makes an untyped project degrade gracefully instead.
169
+ it('omits the require-lazy-promise-in-view-props rule entirely when no tsconfig path is resolvable', async () => {
170
+ const config = storefrontV3({
171
+ tailwind: { entryPoint: './src/client/index.css' },
172
+ i18n: { localeDir: './src/i18n/locales/*.json' },
173
+ })
174
+
175
+ const resolved = await config.toConfigs()
176
+ const entry = resolved.find(
177
+ (c) => c.name === 'storefront/v3/require-lazy-promise',
178
+ )
179
+
180
+ expect(entry).toBeUndefined()
181
+ })
128
182
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scayle/eslint-config-storefront",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "description": "SCAYLE Commerce Engine specific eslint configuration",
5
5
  "author": "SCAYLE Commerce Engine",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "@eslint/js": "^10.0.0",
43
43
  "@intlify/eslint-plugin-vue-i18n": "^4.5.1",
44
44
  "@scayle/eslint-plugin-vue-composable": "1.2.1",
45
+ "@typescript-eslint/utils": "^8.70.0",
45
46
  "@vitest/eslint-plugin": "^1.6.20",
46
47
  "eslint-config-flat-gitignore": "^2.3.0",
47
48
  "eslint-flat-config-utils": "^3.2.0",
@@ -56,21 +57,23 @@
56
57
  "eslint-plugin-regexp": "^3.2.0",
57
58
  "eslint-plugin-sonarjs": "^4.2.0",
58
59
  "eslint-plugin-storybook": "^10.5.8",
59
- "eslint-plugin-unicorn": "^73.0.0",
60
+ "eslint-plugin-unicorn": "^76.0.0",
60
61
  "eslint-plugin-vue": "^10.10.0",
61
62
  "eslint-plugin-vuejs-accessibility": "^2.6.0",
62
63
  "globals": "^17.7.0",
63
- "typescript-eslint": "^8.67.0",
64
+ "ts-api-utils": "^2.1.2",
65
+ "typescript-eslint": "^8.70.0",
64
66
  "vue-eslint-parser": "^10.4.1"
65
67
  },
66
68
  "devDependencies": {
67
69
  "@types/node": "^24",
68
- "@vitest/coverage-v8": "4.1.11",
69
- "eslint": "10.9.1",
70
+ "@typescript-eslint/rule-tester": "^8.70.0",
71
+ "@vitest/coverage-v8": "5.0.1",
72
+ "eslint": "10.11.0",
70
73
  "eslint-formatter-gitlab": "7.2.0",
71
74
  "publint": "0.3.24",
72
75
  "typescript": "6.0.3",
73
- "vitest": "4.1.11"
76
+ "vitest": "5.0.1"
74
77
  },
75
78
  "scripts": {
76
79
  "lint": "eslint .",