@hs-web-team/eslint-config-node 5.0.0 → 5.1.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/.stylelintrc.json +2 -2
- package/README.md +22 -0
- package/base-rules.js +61 -0
- package/bin/gherkin-tag-coverage.js +62 -0
- package/browser.js +5 -60
- package/examples/browser-usage.md +20 -0
- package/examples/custom-rules.md +39 -0
- package/examples/gherkin-usage.md +133 -0
- package/gherkin/.gplintrc +32 -0
- package/gherkin/coverage-config.js +10 -0
- package/gherkin/load-config.js +23 -0
- package/gherkin/suite-coverage.js +126 -0
- package/index.js +4 -53
- package/package.json +18 -3
- package/plugins/hs-web-team/index.js +3 -0
- package/plugins/hs-web-team/rules/no-non-literal-constant-case.js +137 -0
package/.stylelintrc.json
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scss/dollar-variable-pattern": null,
|
|
23
23
|
"selector-class-pattern": [
|
|
24
|
-
"
|
|
24
|
+
"^(?:-?[a-z0-9](?:[a-z0-9]|-(?!-))*|[a-z0-9]+(?:-[a-z0-9]+)*(?:__[a-z0-9]+(?:-[a-z0-9]+)*)?(?:--[a-z0-9]+(?:-[a-z0-9]+)*)?)$",
|
|
25
25
|
{
|
|
26
|
-
"message": "Class names
|
|
26
|
+
"message": "Class names must be kebab-case (e.g. block-element), or strict BEM (block__element--modifier) with at most one __element and one --modifier, in that order",
|
|
27
27
|
"severity": "warning"
|
|
28
28
|
}
|
|
29
29
|
],
|
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ This package provides ESLint rules and configurations for **Hubspot Marketing We
|
|
|
10
10
|
- [Browser/React Setup](#browserreact-setup)
|
|
11
11
|
- [Stylelint Setup](#stylelint-setup)
|
|
12
12
|
- [Cypress Setup](#cypress-setup)
|
|
13
|
+
- [Gherkin Tag Linting Setup](#gherkin-tag-linting-setup)
|
|
13
14
|
- [Accessibility Testing](#accessibility-testing-optional)
|
|
14
15
|
- [Custom Rules](#custom-rules)
|
|
15
16
|
- [Where to use it](#where-to-use-it)
|
|
@@ -126,6 +127,26 @@ This package provides shared Cypress configuration for E2E testing.
|
|
|
126
127
|
|
|
127
128
|
For detailed Cypress configuration and migration documentation, see [examples/cypress-usage.md](./examples/cypress-usage.md).
|
|
128
129
|
|
|
130
|
+
## Gherkin Tag Linting Setup
|
|
131
|
+
|
|
132
|
+
Cucumber selects tests by tag, so an untagged scenario is silently skipped and a green suite can hide tests that never ran. This package ships two checks that make that a build failure.
|
|
133
|
+
|
|
134
|
+
This applies only to repositories whose Cypress suite is written in Gherkin `.feature` files. A Cypress suite written in Mocha has no tags to check. Neither check is part of `wtConfig`, `wtBrowserConfig`, or the Cypress config, so existing consumers are unaffected until they opt in with the script below.
|
|
135
|
+
|
|
136
|
+
1. Add one script to `package.json`
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"scripts": {
|
|
141
|
+
"lint:gherkin": "gplint cypress/e2e --config=node_modules/@hs-web-team/eslint-config-node/gherkin/.gplintrc && gherkin-tag-coverage"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`gplint` is bundled, so there is nothing extra to install. Repositories with no production environment opt out of the prod requirement with a `gherkin-coverage.json` file.
|
|
147
|
+
|
|
148
|
+
For the full rule list, QA-only setup and troubleshooting, see [examples/gherkin-usage.md](./examples/gherkin-usage.md).
|
|
149
|
+
|
|
129
150
|
## Accessibility Testing (optional)
|
|
130
151
|
|
|
131
152
|
This package includes a shared accessibility testing setup using [cypress-axe](https://github.com/component-driven/cypress-axe). Projects can opt in with a single import — no need to install `cypress-axe` or `axe-core` directly.
|
|
@@ -166,6 +187,7 @@ This package ships a `hs-web-team` ESLint plugin with custom rules that are acti
|
|
|
166
187
|
| Rule | Severity | Summary |
|
|
167
188
|
|------|----------|---------|
|
|
168
189
|
| `hs-web-team/no-reduce-accumulator-copy` | error | Disallows O(n²) accumulator copies (spread, concat) inside `reduce`/`reduceRight` |
|
|
190
|
+
| `hs-web-team/no-non-literal-constant-case` | warn | Disallows CONSTANT_CASE names whose value isn't a literal/static constant, or that are declared with `let`/`var` |
|
|
169
191
|
|
|
170
192
|
For examples and remediation guidance, see [examples/custom-rules.md](./examples/custom-rules.md).
|
|
171
193
|
|
package/base-rules.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import globals from 'globals';
|
|
2
|
+
|
|
3
|
+
// Underscore-prefixed HubSpot platform globals (e.g. window._hsq, window._hsg.__ip_lookup).
|
|
4
|
+
// Exported so consuming projects can extend this list with their own no-underscore-dangle
|
|
5
|
+
// allowances without duplicating the shared entries, e.g.:
|
|
6
|
+
// allow: [...underscoreDangleAllowlist, 'myProjectGlobal']
|
|
7
|
+
export const underscoreDangleAllowlist = ['_hsg', '_hsp', '_hsq', '__ip_lookup'];
|
|
8
|
+
|
|
9
|
+
export const nodeGlobals = { ...globals.node, ...globals.es2022, ...globals.jest };
|
|
10
|
+
|
|
11
|
+
export const browserGlobals = {
|
|
12
|
+
...globals.browser,
|
|
13
|
+
...globals.es2021,
|
|
14
|
+
...globals.jest,
|
|
15
|
+
$: true,
|
|
16
|
+
jQuery: true,
|
|
17
|
+
Invoca: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const nodeBaseRules = {
|
|
21
|
+
'no-console': ['error', { allow: ['info', 'warn', 'error'] }],
|
|
22
|
+
camelcase: 'off',
|
|
23
|
+
'comma-dangle': ['warn', 'always-multiline'],
|
|
24
|
+
'arrow-parens': 0,
|
|
25
|
+
'no-plusplus': 0,
|
|
26
|
+
'no-underscore-dangle': ['error', { allow: ['__dirname', '__filename'] }],
|
|
27
|
+
'no-confusing-arrow': 0,
|
|
28
|
+
'import/no-unresolved': 0,
|
|
29
|
+
'import/prefer-default-export': 0,
|
|
30
|
+
'no-trailing-spaces': ['error', { skipBlankLines: true }],
|
|
31
|
+
'no-unused-expressions': ['warn', { allowTernary: true }],
|
|
32
|
+
'max-len': [2, { code: 120, ignoreStrings: true, ignoreTemplateLiterals: true }],
|
|
33
|
+
'operator-linebreak': 0,
|
|
34
|
+
'implicit-arrow-linebreaks': 0,
|
|
35
|
+
'implicit-arrow-linebreak': 0,
|
|
36
|
+
'object-curly-newline': 0,
|
|
37
|
+
'newline-per-chained-call': 0,
|
|
38
|
+
indent: 0,
|
|
39
|
+
'function-paren-newline': 0,
|
|
40
|
+
'max-params': ['warn', { max: 3 }],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const browserBaseRules = {
|
|
44
|
+
'comma-dangle': ['warn', 'always-multiline'],
|
|
45
|
+
'no-param-reassign': ['warn', { props: false }],
|
|
46
|
+
'arrow-parens': 0,
|
|
47
|
+
'no-plusplus': 0,
|
|
48
|
+
'no-console': ['error', { allow: ['warn', 'error'] }],
|
|
49
|
+
'no-confusing-arrow': 0,
|
|
50
|
+
'no-underscore-dangle': ['error', { allow: underscoreDangleAllowlist }],
|
|
51
|
+
'no-trailing-spaces': ['error', { skipBlankLines: true }],
|
|
52
|
+
'no-unused-expressions': ['warn', { allowTernary: true }],
|
|
53
|
+
'max-len': [2, { code: 120, ignoreStrings: true, ignoreTemplateLiterals: true }],
|
|
54
|
+
'operator-linebreak': 0,
|
|
55
|
+
'implicit-arrow-linebreak': 0,
|
|
56
|
+
indent: 0,
|
|
57
|
+
'object-curly-newline': 0,
|
|
58
|
+
'function-paren-newline': 0,
|
|
59
|
+
'nonblock-statement-body-position': 0,
|
|
60
|
+
'max-params': ['warn', { max: 3 }],
|
|
61
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fails when the suite as a whole is missing a required tag combination, e.g. no scenario
|
|
4
|
+
* anywhere is tagged @smoke @prod @en, which would make that CI job select zero specs and
|
|
5
|
+
* exit green.
|
|
6
|
+
*
|
|
7
|
+
* Silent on success, like gplint and eslint.
|
|
8
|
+
* Exit codes: 0 covered, 1 uncovered, 2 bad input.
|
|
9
|
+
*
|
|
10
|
+
* Usage: gherkin-tag-coverage [path] (path defaults to cypress/e2e)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { loadCoverageConfig } from '../gherkin/load-config.js';
|
|
17
|
+
import { findFeatureFiles, collectScenarios, findUncovered, formatProblems } from '../gherkin/suite-coverage.js';
|
|
18
|
+
|
|
19
|
+
const target = process.argv[2] ?? path.join('cypress', 'e2e');
|
|
20
|
+
|
|
21
|
+
if (!fs.existsSync(target)) {
|
|
22
|
+
console.error(`gherkin-tag-coverage: path not found: ${target}`);
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let combinations;
|
|
27
|
+
try {
|
|
28
|
+
({ combinations } = await loadCoverageConfig());
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error(`gherkin-tag-coverage: ${err.message}`);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const files = findFeatureFiles(target);
|
|
35
|
+
if (files.length === 0) {
|
|
36
|
+
console.error(`gherkin-tag-coverage: no .feature files found under ${target}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let scenarios;
|
|
41
|
+
try {
|
|
42
|
+
scenarios = collectScenarios(files);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`gherkin-tag-coverage: could not parse feature files. ${err.message}`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (scenarios.length === 0) {
|
|
49
|
+
console.error(
|
|
50
|
+
`gherkin-tag-coverage: found ${files.length} feature file(s) under ${target} but no scenarios in any of them.`,
|
|
51
|
+
);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const problems = findUncovered(scenarios, combinations);
|
|
56
|
+
|
|
57
|
+
if (problems.length === 0) {
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.info(formatProblems(problems));
|
|
62
|
+
process.exit(1);
|
package/browser.js
CHANGED
|
@@ -1,47 +1,13 @@
|
|
|
1
1
|
import js from '@eslint/js';
|
|
2
|
-
import globals from 'globals';
|
|
3
2
|
import tseslint from 'typescript-eslint';
|
|
4
3
|
import reactPlugin from 'eslint-plugin-react';
|
|
5
4
|
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
|
6
5
|
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
|
7
6
|
import { claudeCodePlugin } from './plugins/claude-code/index.js';
|
|
8
7
|
import { hsWebTeamPlugin } from './plugins/hs-web-team/index.js';
|
|
8
|
+
import { underscoreDangleAllowlist, browserBaseRules as baseRules, browserGlobals } from './base-rules.js';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
const baseRules = {
|
|
12
|
-
'comma-dangle': ['warn', 'always-multiline'],
|
|
13
|
-
'no-param-reassign': ['warn', { props: false }],
|
|
14
|
-
'arrow-parens': 0,
|
|
15
|
-
'no-plusplus': 0,
|
|
16
|
-
'no-console': ['error', { allow: ['warn', 'error'] }],
|
|
17
|
-
'no-confusing-arrow': 0,
|
|
18
|
-
'no-underscore-dangle': [
|
|
19
|
-
'error',
|
|
20
|
-
{
|
|
21
|
-
allow: [
|
|
22
|
-
'_hsg',
|
|
23
|
-
'_hsq',
|
|
24
|
-
],
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
'no-trailing-spaces': ['error', { skipBlankLines: true }],
|
|
28
|
-
'no-unused-expressions': ['warn', { allowTernary: true }],
|
|
29
|
-
'max-len': [
|
|
30
|
-
2,
|
|
31
|
-
{
|
|
32
|
-
code: 120,
|
|
33
|
-
ignoreStrings: true,
|
|
34
|
-
ignoreTemplateLiterals: true,
|
|
35
|
-
},
|
|
36
|
-
],
|
|
37
|
-
'operator-linebreak': 0,
|
|
38
|
-
'implicit-arrow-linebreak': 0,
|
|
39
|
-
indent: 0,
|
|
40
|
-
'object-curly-newline': 0,
|
|
41
|
-
'function-paren-newline': 0,
|
|
42
|
-
'nonblock-statement-body-position': 0,
|
|
43
|
-
'max-params': ['warn', { max: 3 }],
|
|
44
|
-
};
|
|
10
|
+
export { underscoreDangleAllowlist };
|
|
45
11
|
|
|
46
12
|
// React-specific rules
|
|
47
13
|
const reactRules = {
|
|
@@ -60,13 +26,7 @@ const reactRules = {
|
|
|
60
26
|
};
|
|
61
27
|
|
|
62
28
|
// Common ignore patterns
|
|
63
|
-
const commonIgnores = [
|
|
64
|
-
'**/node_modules/**',
|
|
65
|
-
'**/dist/**',
|
|
66
|
-
'**/build/**',
|
|
67
|
-
'**/.next/**',
|
|
68
|
-
'**/coverage/**',
|
|
69
|
-
];
|
|
29
|
+
const commonIgnores = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/coverage/**'];
|
|
70
30
|
|
|
71
31
|
export default [
|
|
72
32
|
// Global ignores
|
|
@@ -81,15 +41,7 @@ export default [
|
|
|
81
41
|
languageOptions: {
|
|
82
42
|
ecmaVersion: 2021,
|
|
83
43
|
sourceType: 'module',
|
|
84
|
-
globals:
|
|
85
|
-
...globals.browser,
|
|
86
|
-
...globals.es2021,
|
|
87
|
-
...globals.jest,
|
|
88
|
-
// Custom browser globals from original config
|
|
89
|
-
$: true,
|
|
90
|
-
jQuery: true,
|
|
91
|
-
Invoca: true,
|
|
92
|
-
},
|
|
44
|
+
globals: browserGlobals,
|
|
93
45
|
parserOptions: {
|
|
94
46
|
ecmaFeatures: {
|
|
95
47
|
jsx: true,
|
|
@@ -131,14 +83,7 @@ export default [
|
|
|
131
83
|
languageOptions: {
|
|
132
84
|
ecmaVersion: 2021,
|
|
133
85
|
sourceType: 'module',
|
|
134
|
-
globals:
|
|
135
|
-
...globals.browser,
|
|
136
|
-
...globals.es2021,
|
|
137
|
-
...globals.jest,
|
|
138
|
-
$: true,
|
|
139
|
-
jQuery: true,
|
|
140
|
-
Invoca: true,
|
|
141
|
-
},
|
|
86
|
+
globals: browserGlobals,
|
|
142
87
|
parserOptions: {
|
|
143
88
|
ecmaFeatures: {
|
|
144
89
|
jsx: true,
|
|
@@ -57,6 +57,26 @@ If you're migrating from `@hs-web-team/eslint-config-browser` (ESLint 8):
|
|
|
57
57
|
3. Ensure you're using Node.js >= 22
|
|
58
58
|
4. Review and adapt any custom rules in your project
|
|
59
59
|
|
|
60
|
+
## Extending the underscore-dangle allowlist
|
|
61
|
+
|
|
62
|
+
`no-underscore-dangle` allows a fixed set of HubSpot platform globals (`_hsg`, `_hsp`, `_hsq`, `__ip_lookup`). If your project reads an additional underscore-prefixed global, extend the shared list instead of overriding the rule entirely:
|
|
63
|
+
|
|
64
|
+
```javascript
|
|
65
|
+
import wtBrowserConfig, { underscoreDangleAllowlist } from '@hs-web-team/eslint-config-node/browser';
|
|
66
|
+
|
|
67
|
+
export default [
|
|
68
|
+
...wtBrowserConfig,
|
|
69
|
+
{
|
|
70
|
+
rules: {
|
|
71
|
+
'no-underscore-dangle': [
|
|
72
|
+
'error',
|
|
73
|
+
{ allow: [...underscoreDangleAllowlist, 'myProjectGlobal'] },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
```
|
|
79
|
+
|
|
60
80
|
## File Patterns
|
|
61
81
|
|
|
62
82
|
The configuration applies to:
|
package/examples/custom-rules.md
CHANGED
|
@@ -57,3 +57,42 @@ const counts = items.reduce((acc, item) => {
|
|
|
57
57
|
return acc;
|
|
58
58
|
}, {});
|
|
59
59
|
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## `hs-web-team/no-non-literal-constant-case` ⚠️ warn
|
|
64
|
+
|
|
65
|
+
**Disallows `CONSTANT_CASE` names whose value isn't actually static.**
|
|
66
|
+
|
|
67
|
+
A `CONSTANT_CASE` name signals to readers that the value is fixed and safe to assume unchanged for the lifetime of the program. When the value is actually computed at runtime — a function call, a conditional, a reference to a mutable binding — the name is misleading and can cost real debugging time (this rule was added after a `CONSTANT_CASE`-named variable turned out to be a debug-only runtime computation, defaulting to `0` for normal visitors).
|
|
68
|
+
|
|
69
|
+
### What triggers it
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// ❌ Conditional — depends on runtime state
|
|
73
|
+
const REDIRECT_DELAY_MS = isDebugMode() ? 1000 : 0;
|
|
74
|
+
|
|
75
|
+
// ❌ Function call result
|
|
76
|
+
const TOKEN = generateToken();
|
|
77
|
+
|
|
78
|
+
// ❌ Declared with let/var — can be reassigned regardless of its current value
|
|
79
|
+
let API_HOST = 'www.api-host.com';
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### What to do instead
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
// ✅ Rename to camelCase if the value genuinely isn't a constant
|
|
86
|
+
const redirectDelayMs = isDebugMode() ? 1000 : 0;
|
|
87
|
+
|
|
88
|
+
// ✅ Keep CONSTANT_CASE only when the value is a literal (or built purely from literals)
|
|
89
|
+
const API_HOST = 'www.api-host.com';
|
|
90
|
+
const RETRY_LIMITS = Object.freeze({ max: 3, backoffMs: 500 });
|
|
91
|
+
const SORT_METHODS = new Set(['sort', 'toSorted']);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### What's allowed
|
|
95
|
+
|
|
96
|
+
Literals, template literals with no interpolation, unary-wrapped literals (`-1`), arrays/objects composed entirely of the above (optionally wrapped in `Object.freeze(...)`), `new Set`/`new Map`/`new RegExp` (and their `Weak` variants) built from literal arguments, `process.env.X` references, and references to other `CONSTANT_CASE`-named identifiers are all treated as static and won't be flagged.
|
|
97
|
+
|
|
98
|
+
**Not covered:** this rule only checks whether the *value* is static — it does not enforce that `CONSTANT_CASE` be reserved for exported bindings (a separate naming convention with much broader rollout impact, intentionally left out of this rule for now).
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Gherkin Tag Linting Usage
|
|
2
|
+
|
|
3
|
+
This package provides shared tag linting for Cypress/Cucumber feature files.
|
|
4
|
+
|
|
5
|
+
Cucumber selects which tests to run by tag, so a scenario with no tags is silently skipped. A
|
|
6
|
+
suite can be fully green while some of its tests never execute. These two checks turn that into
|
|
7
|
+
a build failure.
|
|
8
|
+
|
|
9
|
+
## Applies to
|
|
10
|
+
|
|
11
|
+
Only repositories whose Cypress suite is written in Gherkin, using
|
|
12
|
+
`@badeball/cypress-cucumber-preprocessor` and `.feature` files.
|
|
13
|
+
|
|
14
|
+
Both checks read `.feature` files and nothing else, so:
|
|
15
|
+
|
|
16
|
+
- A Cypress suite written in Mocha (`.cy.js` / `.cy.ts` with `describe`/`it`) has nothing to
|
|
17
|
+
check. There are no tags to require, because Mocha does not select tests by tag.
|
|
18
|
+
- Existing consumers of `@hs-web-team/eslint-config-node` are unaffected by upgrading. Neither
|
|
19
|
+
check is wired into `wtConfig`, `wtBrowserConfig`, or any Cypress config. Nothing runs until a
|
|
20
|
+
repository adds the `lint:gherkin` script below.
|
|
21
|
+
|
|
22
|
+
## What is checked
|
|
23
|
+
|
|
24
|
+
**Per scenario**, every scenario must carry:
|
|
25
|
+
|
|
26
|
+
- at least one environment tag: `@qa` or `@prod`. Both together is valid.
|
|
27
|
+
- at least one locale tag: `@en`, `@es`, `@fr`, `@de`, `@jp`, `@br`. Any number together is valid.
|
|
28
|
+
|
|
29
|
+
Any other tag is allowed and ignored, including TestRail ids like `@a72w9o`. There is no tag
|
|
30
|
+
allowlist.
|
|
31
|
+
|
|
32
|
+
A tag counts wherever it is declared: the `Feature` line, a `Rule:`, the `Scenario:`, or an
|
|
33
|
+
`Examples:` block. Inheritance works the same way as Cucumber's own `--tags` filtering.
|
|
34
|
+
|
|
35
|
+
**Per suite**, the suite must contain at least one scenario tagged `@smoke @qa @en` and at least
|
|
36
|
+
one tagged `@smoke @prod @en`. The same scenario may satisfy both, for example one tagged
|
|
37
|
+
`@qa @prod @en @smoke`.
|
|
38
|
+
|
|
39
|
+
This second check cannot be a lint rule, because a lint rule only ever sees one file at a time
|
|
40
|
+
and never the whole suite.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm i -D @hs-web-team/eslint-config-node@latest
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`gplint` and the Gherkin parser are bundled, so there is nothing else to install.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
Add one script to your `package.json`:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"scripts": {
|
|
57
|
+
"lint:gherkin": "gplint cypress/e2e --config=node_modules/@hs-web-team/eslint-config-node/gherkin/.gplintrc && gherkin-tag-coverage"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Then run it:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm run lint:gherkin
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Both checks print nothing when they pass. A failure looks like this:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
/path/to/cypress/e2e/login.feature
|
|
72
|
+
3:3 error The tag(s) [@qa,@prod] should be present for Scenario. required-tags
|
|
73
|
+
3:3 error The tag(s) [@en,@es,@fr,@de,@jp,@br] should be present for Scenario. required-tags
|
|
74
|
+
|
|
75
|
+
✖ 2 problems (2 errors, 0 warnings)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use `&&` rather than `;` to join the two commands. With `;` the exit code comes from the last
|
|
79
|
+
command only, so a tag violation would pass CI.
|
|
80
|
+
|
|
81
|
+
## QA-only repositories
|
|
82
|
+
|
|
83
|
+
If a repository has no production environment, the `@smoke @prod @en` requirement can never be
|
|
84
|
+
satisfied. Opt out by adding a `gherkin-coverage.json` next to `package.json`:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{ "requiredCombinations": [["@smoke", "@qa", "@en"]] }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Do not satisfy the requirement by tagging a scenario `@prod` instead. Tags are what CI selects
|
|
91
|
+
on, so that scenario would become eligible for the production run.
|
|
92
|
+
|
|
93
|
+
A bare array works too:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
[["@smoke", "@qa", "@en"]]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Passing a different path
|
|
100
|
+
|
|
101
|
+
Both commands default to `cypress/e2e`. To point elsewhere:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
gplint tests/features --config=node_modules/@hs-web-team/eslint-config-node/gherkin/.gplintrc
|
|
105
|
+
gherkin-tag-coverage tests/features
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Pass a **directory**, not a glob. `gplint` rewrites patterns containing `/**`, so
|
|
109
|
+
`cypress/e2e/**/*.feature` matches nothing. Directories recurse to any depth.
|
|
110
|
+
|
|
111
|
+
## Notes
|
|
112
|
+
|
|
113
|
+
- `gplint` exits 0 when it matches no feature files, so a wrong path looks the same as a pass.
|
|
114
|
+
Check that the reported file count is non-zero when first setting this up, using
|
|
115
|
+
`--format=json` to list the files it found.
|
|
116
|
+
- Tags are case sensitive. `@EN` does not satisfy `@en`, which is correct, because Cucumber
|
|
117
|
+
would not select it either.
|
|
118
|
+
- A scenario written after a `Rule:` belongs to that Rule even if it is dedented back to feature
|
|
119
|
+
level, and inherits the Rule's tags. This is Gherkin behaviour, not a linter quirk.
|
|
120
|
+
- An `Examples` table row cannot be tagged individually. Tags attach to the `Examples:` block and
|
|
121
|
+
every row inherits them.
|
|
122
|
+
- Whitespace and formatting rules are deliberately not included, because Prettier owns those via
|
|
123
|
+
the bundled `prettier-plugin-gherkin`.
|
|
124
|
+
- `gplint` prints an `ExperimentalWarning: Importing JSON modules` line to stderr on every run.
|
|
125
|
+
It is harmless.
|
|
126
|
+
|
|
127
|
+
## Exit codes
|
|
128
|
+
|
|
129
|
+
| Code | Meaning |
|
|
130
|
+
| ---- | ----------------------------------------------------------------------------------------- |
|
|
131
|
+
| 0 | Passed |
|
|
132
|
+
| 1 | A real violation, or a missing suite-wide combination |
|
|
133
|
+
| 2 | Bad input, for example an unparseable feature file or a malformed `gherkin-coverage.json` |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Every scenario needs one tag from each list. Inherited tags count (Feature, Rule,
|
|
3
|
+
// Examples). Other tags are ignored. ignoreUntagged must stay false or fully untagged
|
|
4
|
+
// scenarios are skipped.
|
|
5
|
+
"required-tags": ["error", {
|
|
6
|
+
"ignoreUntagged": false,
|
|
7
|
+
"global": [
|
|
8
|
+
["@qa", "@prod"],
|
|
9
|
+
["@en", "@es", "@fr", "@de", "@jp", "@br"]
|
|
10
|
+
]
|
|
11
|
+
}],
|
|
12
|
+
|
|
13
|
+
"no-duplicate-tags": "error",
|
|
14
|
+
// allowSeparated:false so "@qa @en #@smoke" fails. The default true allows it.
|
|
15
|
+
"no-partially-commented-tag-lines": ["error", { "allowSeparated": false }],
|
|
16
|
+
|
|
17
|
+
"no-empty-file": "error",
|
|
18
|
+
"no-files-without-scenarios": "error",
|
|
19
|
+
"no-unnamed-features": "error",
|
|
20
|
+
"no-unnamed-scenarios": "error",
|
|
21
|
+
"no-scenario-outlines-without-examples": "error",
|
|
22
|
+
"no-empty-background": "error",
|
|
23
|
+
"no-background-only-scenario": "error",
|
|
24
|
+
"no-dupe-feature-names": "error",
|
|
25
|
+
// in-feature, not the default "anywhere", which flags the same name in unrelated features.
|
|
26
|
+
"no-dupe-scenario-names": ["error", "in-feature"],
|
|
27
|
+
"use-and": "error",
|
|
28
|
+
"name-length": ["error", { "Feature": 60, "Scenario": 120, "Step": 475 }]
|
|
29
|
+
|
|
30
|
+
// Whitespace rules (indentation, no-trailing-spaces, new-line-at-eof,
|
|
31
|
+
// no-multiple-empty-lines) left off: prettier owns those.
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Tag combinations the suite as a whole must contain at least one scenario for.
|
|
2
|
+
// One scenario may satisfy several entries, e.g. a scenario tagged @qa @prod @en @smoke
|
|
3
|
+
// covers both of these.
|
|
4
|
+
//
|
|
5
|
+
// Override per repo with a `gherkin-coverage.json` file next to package.json, either as a
|
|
6
|
+
// bare array of combinations or as `{ "requiredCombinations": [...] }`.
|
|
7
|
+
export default [
|
|
8
|
+
['@smoke', '@qa', '@en'],
|
|
9
|
+
['@smoke', '@prod', '@en'],
|
|
10
|
+
];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import stripJsonComments from 'strip-json-comments';
|
|
4
|
+
|
|
5
|
+
import defaultCombinations from './coverage-config.js';
|
|
6
|
+
|
|
7
|
+
const OVERRIDE_FILE = 'gherkin-coverage.json';
|
|
8
|
+
|
|
9
|
+
export async function loadCoverageConfig(cwd = process.cwd()) {
|
|
10
|
+
const overridePath = path.join(cwd, OVERRIDE_FILE);
|
|
11
|
+
if (!fs.existsSync(overridePath)) {
|
|
12
|
+
return { combinations: defaultCombinations, overridePath: null };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const override = JSON.parse(stripJsonComments(fs.readFileSync(overridePath, 'utf8')));
|
|
16
|
+
const combinations = Array.isArray(override) ? override : override?.requiredCombinations;
|
|
17
|
+
|
|
18
|
+
if (!Array.isArray(combinations) || combinations.some(combo => !Array.isArray(combo))) {
|
|
19
|
+
throw new Error(`${overridePath} must be an array of tag arrays, or { "requiredCombinations": [[...]] }.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return { combinations, overridePath };
|
|
23
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks that the suite contains at least one scenario for each required tag combination.
|
|
3
|
+
*
|
|
4
|
+
* A gplint rule cannot do this: it is handed one file at a time and never sees the suite.
|
|
5
|
+
*
|
|
6
|
+
* Tags count no matter where they come from: the Feature line, a Rule, the Scenario, or an
|
|
7
|
+
* Examples block. This works because each file is compiled into "pickles" the same way
|
|
8
|
+
* Cucumber does, and a pickle's tag list is already the merged set Cucumber matches against
|
|
9
|
+
* when you run --tags.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import * as gherkin from '@cucumber/gherkin';
|
|
15
|
+
import * as messages from '@cucumber/messages';
|
|
16
|
+
|
|
17
|
+
export const RULE = 'suite-tag-coverage';
|
|
18
|
+
|
|
19
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
|
|
20
|
+
|
|
21
|
+
export function findFeatureFiles(target) {
|
|
22
|
+
if (fs.statSync(target).isFile()) {
|
|
23
|
+
return target.endsWith('.feature') ? [target] : [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const found = [];
|
|
27
|
+
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
|
|
28
|
+
const full = path.join(target, entry.name);
|
|
29
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
30
|
+
found.push(...findFeatureFiles(full));
|
|
31
|
+
} else if (entry.isFile() && entry.name.endsWith('.feature')) {
|
|
32
|
+
found.push(full);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parse(filePath) {
|
|
39
|
+
const parser = new gherkin.Parser(
|
|
40
|
+
new gherkin.AstBuilder(messages.IdGenerator.uuid()),
|
|
41
|
+
new gherkin.GherkinClassicTokenMatcher(),
|
|
42
|
+
);
|
|
43
|
+
const doc = parser.parse(fs.readFileSync(filePath, 'utf8'));
|
|
44
|
+
return { pickles: gherkin.compile(doc, filePath, messages.IdGenerator.uuid()), doc };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A pickle knows which AST nodes it came from but not its line number, so build id -> line.
|
|
48
|
+
function lineIndex(doc) {
|
|
49
|
+
const lines = new Map();
|
|
50
|
+
const visit = children => {
|
|
51
|
+
for (const child of children ?? []) {
|
|
52
|
+
if (child.rule) visit(child.rule.children);
|
|
53
|
+
if (!child.scenario) continue;
|
|
54
|
+
lines.set(child.scenario.id, child.scenario.location.line);
|
|
55
|
+
for (const examples of child.scenario.examples ?? []) {
|
|
56
|
+
for (const row of examples.tableBody ?? []) {
|
|
57
|
+
lines.set(row.id, row.location.line);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
visit(doc.feature?.children);
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function collectScenarios(files) {
|
|
67
|
+
const scenarios = [];
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const parsed = parse(file);
|
|
70
|
+
const lines = lineIndex(parsed.doc);
|
|
71
|
+
for (const pickle of parsed.pickles) {
|
|
72
|
+
scenarios.push({
|
|
73
|
+
file,
|
|
74
|
+
name: pickle.name,
|
|
75
|
+
line: lines.get(pickle.astNodeIds.at(-1)) ?? '?',
|
|
76
|
+
tags: new Set(pickle.tags.map(tag => tag.name)),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return scenarios;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Returns one problem per uncovered combination, anchored at the closest scenario. */
|
|
84
|
+
export function findUncovered(scenarios, combinations) {
|
|
85
|
+
return combinations
|
|
86
|
+
.filter(combo => !scenarios.some(scenario => combo.every(tag => scenario.tags.has(tag))))
|
|
87
|
+
.map(combo => {
|
|
88
|
+
// Rank by how many tags are missing so the suggestion is the smallest possible fix.
|
|
89
|
+
const closest = scenarios
|
|
90
|
+
.map(scenario => ({ scenario, missing: combo.filter(tag => !scenario.tags.has(tag)) }))
|
|
91
|
+
.sort((a, b) => a.missing.length - b.missing.length)[0];
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
file: closest.scenario.file,
|
|
95
|
+
line: closest.scenario.line,
|
|
96
|
+
column: 3,
|
|
97
|
+
message: `No scenario in the suite is tagged [${combo.join(' ')}]. Add ${closest.missing.join(' ')} here, or tag another scenario.`,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** gplint's stylish format, so the lint and coverage checks read as one tool. */
|
|
103
|
+
export function formatProblems(problems) {
|
|
104
|
+
const byFile = new Map();
|
|
105
|
+
for (const problem of problems) {
|
|
106
|
+
if (!byFile.has(problem.file)) byFile.set(problem.file, []);
|
|
107
|
+
byFile.get(problem.file).push(problem);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const width = Math.max(...problems.map(problem => `${problem.line}:${problem.column}`.length));
|
|
111
|
+
const lines = [];
|
|
112
|
+
|
|
113
|
+
for (const [file, fileProblems] of [...byFile.entries()].sort()) {
|
|
114
|
+
lines.push('', path.resolve(file));
|
|
115
|
+
for (const problem of fileProblems.sort((a, b) => a.line - b.line)) {
|
|
116
|
+
const at = `${problem.line}:${problem.column}`.padStart(width);
|
|
117
|
+
lines.push(` ${at} error ${problem.message} ${RULE}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const count = problems.length;
|
|
122
|
+
const plural = count === 1 ? '' : 's';
|
|
123
|
+
lines.push('', `✖ ${count} problem${plural} (${count} error${plural}, 0 warnings)`, '');
|
|
124
|
+
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
package/index.js
CHANGED
|
@@ -1,60 +1,11 @@
|
|
|
1
1
|
import js from '@eslint/js';
|
|
2
|
-
import globals from 'globals';
|
|
3
2
|
import tseslint from 'typescript-eslint';
|
|
4
3
|
import { claudeCodePlugin } from './plugins/claude-code/index.js';
|
|
5
4
|
import { hsWebTeamPlugin } from './plugins/hs-web-team/index.js';
|
|
6
|
-
|
|
7
|
-
// Base rules for all JavaScript files
|
|
8
|
-
const baseRules = {
|
|
9
|
-
'no-console': [
|
|
10
|
-
'error',
|
|
11
|
-
{
|
|
12
|
-
allow: ['info', 'warn', 'error'],
|
|
13
|
-
},
|
|
14
|
-
],
|
|
15
|
-
camelcase: 'off',
|
|
16
|
-
'comma-dangle': ['warn', 'always-multiline'],
|
|
17
|
-
'arrow-parens': 0,
|
|
18
|
-
'no-plusplus': 0,
|
|
19
|
-
'no-underscore-dangle': [
|
|
20
|
-
'error',
|
|
21
|
-
{
|
|
22
|
-
allow: [
|
|
23
|
-
'__dirname',
|
|
24
|
-
'__filename',
|
|
25
|
-
],
|
|
26
|
-
},
|
|
27
|
-
],
|
|
28
|
-
'no-confusing-arrow': 0,
|
|
29
|
-
'import/no-unresolved': 0,
|
|
30
|
-
'import/prefer-default-export': 0,
|
|
31
|
-
'no-trailing-spaces': ['error', { skipBlankLines: true }],
|
|
32
|
-
'no-unused-expressions': ['warn', { allowTernary: true }],
|
|
33
|
-
'max-len': [
|
|
34
|
-
2,
|
|
35
|
-
{
|
|
36
|
-
code: 120,
|
|
37
|
-
ignoreStrings: true,
|
|
38
|
-
ignoreTemplateLiterals: true,
|
|
39
|
-
},
|
|
40
|
-
],
|
|
41
|
-
'operator-linebreak': 0,
|
|
42
|
-
'implicit-arrow-linebreaks': 0,
|
|
43
|
-
'implicit-arrow-linebreak': 0,
|
|
44
|
-
'object-curly-newline': 0,
|
|
45
|
-
'newline-per-chained-call': 0,
|
|
46
|
-
indent: 0,
|
|
47
|
-
'function-paren-newline': 0,
|
|
48
|
-
'max-params': ['warn', { max: 3 }],
|
|
49
|
-
};
|
|
5
|
+
import { nodeBaseRules as baseRules, nodeGlobals } from './base-rules.js';
|
|
50
6
|
|
|
51
7
|
// Common ignore patterns
|
|
52
|
-
const commonIgnores = [
|
|
53
|
-
'**/node_modules/**',
|
|
54
|
-
'**/.serverless/**',
|
|
55
|
-
'**/.webpack/**',
|
|
56
|
-
'**/dist/**',
|
|
57
|
-
];
|
|
8
|
+
const commonIgnores = ['**/node_modules/**', '**/.serverless/**', '**/.webpack/**', '**/dist/**'];
|
|
58
9
|
|
|
59
10
|
export default [
|
|
60
11
|
// Global ignores
|
|
@@ -67,7 +18,7 @@ export default [
|
|
|
67
18
|
{
|
|
68
19
|
files: ['**/*.{js,mjs,cjs}'],
|
|
69
20
|
languageOptions: {
|
|
70
|
-
globals:
|
|
21
|
+
globals: nodeGlobals,
|
|
71
22
|
},
|
|
72
23
|
rules: baseRules,
|
|
73
24
|
},
|
|
@@ -80,7 +31,7 @@ export default [
|
|
|
80
31
|
{
|
|
81
32
|
files: ['**/*.{ts,mts,cts,tsx}'],
|
|
82
33
|
languageOptions: {
|
|
83
|
-
globals:
|
|
34
|
+
globals: nodeGlobals,
|
|
84
35
|
},
|
|
85
36
|
rules: baseRules,
|
|
86
37
|
},
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hs-web-team/eslint-config-node",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "HubSpot Marketing WebTeam shared configurations for ESLint, Prettier, Stylelint, and Cypress",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierrc.json",
|
|
7
7
|
".stylelintrc.json",
|
|
8
|
+
"base-rules.js",
|
|
8
9
|
"bin/",
|
|
9
10
|
"browser.js",
|
|
10
11
|
"cypress.config.cjs",
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
"cypress/",
|
|
13
14
|
"docs/",
|
|
14
15
|
"examples/",
|
|
16
|
+
"gherkin/",
|
|
15
17
|
"index.js",
|
|
16
18
|
"plugins/"
|
|
17
19
|
],
|
|
@@ -31,7 +33,9 @@
|
|
|
31
33
|
"types": "./cypress/index.d.ts",
|
|
32
34
|
"default": "./cypress/index.js"
|
|
33
35
|
},
|
|
34
|
-
"./plugin": "./plugins/hs-web-team/index.js"
|
|
36
|
+
"./plugin": "./plugins/hs-web-team/index.js",
|
|
37
|
+
"./gherkin/.gplintrc": "./gherkin/.gplintrc",
|
|
38
|
+
"./gherkin/coverage-config": "./gherkin/coverage-config.js"
|
|
35
39
|
},
|
|
36
40
|
"typesVersions": {
|
|
37
41
|
"*": {
|
|
@@ -47,13 +51,15 @@
|
|
|
47
51
|
"postinstall": "node bin/check-peer-deps.cjs",
|
|
48
52
|
"lint": "npx eslint . --fix",
|
|
49
53
|
"test": "node --test",
|
|
54
|
+
"playground:build": "node playground/build.js",
|
|
50
55
|
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .githooks || true"
|
|
51
56
|
},
|
|
52
57
|
"engines": {
|
|
53
58
|
"node": ">=24"
|
|
54
59
|
},
|
|
55
60
|
"bin": {
|
|
56
|
-
"add-prettier": "bin/add-prettier-scripts.js"
|
|
61
|
+
"add-prettier": "bin/add-prettier-scripts.js",
|
|
62
|
+
"gherkin-tag-coverage": "bin/gherkin-tag-coverage.js"
|
|
57
63
|
},
|
|
58
64
|
"repository": {
|
|
59
65
|
"type": "git",
|
|
@@ -75,6 +81,8 @@
|
|
|
75
81
|
"homepage": "https://github.com/HubSpotWebTeam/wt-eslint-node#readme",
|
|
76
82
|
"dependencies": {
|
|
77
83
|
"@bahmutov/cypress-esbuild-preprocessor": "2.2.8",
|
|
84
|
+
"@cucumber/gherkin": "38.0.0",
|
|
85
|
+
"@cucumber/messages": "32.3.1",
|
|
78
86
|
"@eslint/eslintrc": "3.3.6",
|
|
79
87
|
"@eslint/js": "^9.39.1",
|
|
80
88
|
"@eslint/markdown": "8.0.3",
|
|
@@ -89,10 +97,12 @@
|
|
|
89
97
|
"eslint-plugin-react": "7.37.5",
|
|
90
98
|
"eslint-plugin-react-hooks": "7.1.1",
|
|
91
99
|
"globals": "17.11.0",
|
|
100
|
+
"gplint": "2.6.0",
|
|
92
101
|
"jiti": "2.7.0",
|
|
93
102
|
"js-yaml": "5.4.0",
|
|
94
103
|
"prettier": "3.9.6",
|
|
95
104
|
"prettier-plugin-gherkin": "4.0.0",
|
|
105
|
+
"strip-json-comments": "5.0.3",
|
|
96
106
|
"typescript-eslint": "8.68.0"
|
|
97
107
|
},
|
|
98
108
|
"peerDependencies": {
|
|
@@ -119,5 +129,10 @@
|
|
|
119
129
|
"cypress": {
|
|
120
130
|
"optional": true
|
|
121
131
|
}
|
|
132
|
+
},
|
|
133
|
+
"devDependencies": {
|
|
134
|
+
"stylelint": "17.15.0",
|
|
135
|
+
"stylelint-config-standard-scss": "17.0.0",
|
|
136
|
+
"stylelint-declaration-strict-value": "1.12.1"
|
|
122
137
|
}
|
|
123
138
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { noAbbreviations } from './rules/no-abbreviations.js';
|
|
2
|
+
import { noNonLiteralConstantCase } from './rules/no-non-literal-constant-case.js';
|
|
2
3
|
import { noReduceAccumulatorCopy } from './rules/no-reduce-accumulator-copy.js';
|
|
3
4
|
|
|
4
5
|
export const hsWebTeamPlugin = {
|
|
5
6
|
rules: {
|
|
6
7
|
'no-abbreviations': noAbbreviations,
|
|
8
|
+
'no-non-literal-constant-case': noNonLiteralConstantCase,
|
|
7
9
|
'no-reduce-accumulator-copy': noReduceAccumulatorCopy,
|
|
8
10
|
},
|
|
9
11
|
};
|
|
@@ -15,6 +17,7 @@ hsWebTeamPlugin.configs = {
|
|
|
15
17
|
plugins: { 'hs-web-team': hsWebTeamPlugin },
|
|
16
18
|
rules: {
|
|
17
19
|
'hs-web-team/no-abbreviations': 'warn',
|
|
20
|
+
'hs-web-team/no-non-literal-constant-case': 'warn',
|
|
18
21
|
'hs-web-team/no-reduce-accumulator-copy': 'error',
|
|
19
22
|
},
|
|
20
23
|
},
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const CONSTANT_CASE_REGEX = /^[A-Z][A-Z0-9_]*$/;
|
|
2
|
+
|
|
3
|
+
// TS wrapper nodes carry no runtime meaning for "is this value static" —
|
|
4
|
+
// unwrap them before inspecting the underlying expression.
|
|
5
|
+
const TS_WRAPPER_TYPES = new Set([
|
|
6
|
+
'TSAsExpression',
|
|
7
|
+
'TSSatisfiesExpression',
|
|
8
|
+
'TSNonNullExpression',
|
|
9
|
+
'TSTypeAssertion',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function unwrap(node) {
|
|
13
|
+
let current = node;
|
|
14
|
+
while (current && TS_WRAPPER_TYPES.has(current.type)) {
|
|
15
|
+
current = current.expression;
|
|
16
|
+
}
|
|
17
|
+
return current;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isProcessEnvAccess(node) {
|
|
21
|
+
return (
|
|
22
|
+
node.type === 'MemberExpression' &&
|
|
23
|
+
node.object.type === 'MemberExpression' &&
|
|
24
|
+
node.object.object.type === 'Identifier' &&
|
|
25
|
+
node.object.object.name === 'process' &&
|
|
26
|
+
node.object.property.type === 'Identifier' &&
|
|
27
|
+
node.object.property.name === 'env'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isObjectFreezeCall(node) {
|
|
32
|
+
return (
|
|
33
|
+
node.type === 'CallExpression' &&
|
|
34
|
+
node.callee.type === 'MemberExpression' &&
|
|
35
|
+
!node.callee.computed &&
|
|
36
|
+
node.callee.object.type === 'Identifier' &&
|
|
37
|
+
node.callee.object.name === 'Object' &&
|
|
38
|
+
node.callee.property.type === 'Identifier' &&
|
|
39
|
+
node.callee.property.name === 'freeze' &&
|
|
40
|
+
node.arguments.length === 1
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Set/Map and friends are the idiomatic "frozen collection" for lookup tables
|
|
45
|
+
// (e.g. `new Set(['a', 'b'])`) — treated as static when every constructor arg is.
|
|
46
|
+
const STATIC_COLLECTION_CONSTRUCTORS = new Set(['Set', 'Map', 'WeakSet', 'WeakMap', 'RegExp']);
|
|
47
|
+
|
|
48
|
+
function isStaticCollectionConstructor(node) {
|
|
49
|
+
return (
|
|
50
|
+
node.type === 'NewExpression' &&
|
|
51
|
+
node.callee.type === 'Identifier' &&
|
|
52
|
+
STATIC_COLLECTION_CONSTRUCTORS.has(node.callee.name)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isStaticElement(node) {
|
|
57
|
+
return isStaticExpression(node.type === 'SpreadElement' ? node.argument : node);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isStaticExpression(rawNode) {
|
|
61
|
+
const node = unwrap(rawNode);
|
|
62
|
+
if (!node) return true;
|
|
63
|
+
|
|
64
|
+
switch (node.type) {
|
|
65
|
+
case 'Literal':
|
|
66
|
+
return true;
|
|
67
|
+
case 'TemplateLiteral':
|
|
68
|
+
return node.expressions.length === 0;
|
|
69
|
+
case 'UnaryExpression':
|
|
70
|
+
return isStaticExpression(node.argument);
|
|
71
|
+
case 'ArrayExpression':
|
|
72
|
+
return node.elements.every((element) => element === null || isStaticElement(element));
|
|
73
|
+
case 'ObjectExpression':
|
|
74
|
+
return node.properties.every((prop) =>
|
|
75
|
+
prop.type === 'SpreadElement'
|
|
76
|
+
? isStaticExpression(prop.argument)
|
|
77
|
+
: isStaticExpression(prop.value),
|
|
78
|
+
);
|
|
79
|
+
case 'CallExpression':
|
|
80
|
+
return isObjectFreezeCall(node) && isStaticExpression(node.arguments[0]);
|
|
81
|
+
case 'NewExpression':
|
|
82
|
+
return isStaticCollectionConstructor(node) && node.arguments.every(isStaticElement);
|
|
83
|
+
case 'Identifier':
|
|
84
|
+
// A reference to another CONSTANT_CASE-named binding is treated as static.
|
|
85
|
+
// This is a syntactic check, not a scope resolution: if that binding's own
|
|
86
|
+
// initializer isn't actually static, this same rule flags it at its declaration.
|
|
87
|
+
return CONSTANT_CASE_REGEX.test(node.name);
|
|
88
|
+
case 'MemberExpression':
|
|
89
|
+
return isProcessEnvAccess(node);
|
|
90
|
+
default:
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const noNonLiteralConstantCase = {
|
|
96
|
+
meta: {
|
|
97
|
+
type: 'problem',
|
|
98
|
+
docs: {
|
|
99
|
+
description:
|
|
100
|
+
'Disallow CONSTANT_CASE variable names whose value is not a literal/static constant',
|
|
101
|
+
recommended: true,
|
|
102
|
+
},
|
|
103
|
+
messages: {
|
|
104
|
+
nonLiteralInit:
|
|
105
|
+
'CONSTANT_CASE name "{{name}}" implies a static constant, but its value is computed ({{ nodeType }}). Rename to camelCase, or replace the value with a literal constant.',
|
|
106
|
+
reassignableBinding:
|
|
107
|
+
'CONSTANT_CASE name "{{name}}" implies a static constant, but it is declared with "{{kind}}" and can be reassigned. Use "const", or rename to camelCase.',
|
|
108
|
+
},
|
|
109
|
+
schema: [],
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
create(context) {
|
|
113
|
+
return {
|
|
114
|
+
VariableDeclarator(node) {
|
|
115
|
+
if (node.id.type !== 'Identifier') return;
|
|
116
|
+
if (!node.init) return;
|
|
117
|
+
|
|
118
|
+
const { name } = node.id;
|
|
119
|
+
if (!CONSTANT_CASE_REGEX.test(name)) return;
|
|
120
|
+
|
|
121
|
+
const { kind } = node.parent;
|
|
122
|
+
if (kind !== 'const') {
|
|
123
|
+
context.report({ node: node.id, messageId: 'reassignableBinding', data: { name, kind } });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (isStaticExpression(node.init)) return;
|
|
128
|
+
|
|
129
|
+
context.report({
|
|
130
|
+
node: node.id,
|
|
131
|
+
messageId: 'nonLiteralInit',
|
|
132
|
+
data: { name, nodeType: unwrap(node.init).type },
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
};
|