@hs-web-team/eslint-config-node 4.2.2 → 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 +6 -3
- package/README.md +33 -0
- package/base-rules.js +61 -0
- package/bin/check-peer-deps.cjs +1 -1
- package/bin/gherkin-tag-coverage.js +62 -0
- package/browser.js +13 -62
- package/cypress.config.cjs +10 -11
- package/docs/adr/0001-lint-claude-md-with-eslint.md +123 -0
- package/docs/adr/0002-extend-max-lines-to-skill-md.md +105 -0
- package/examples/browser-usage.md +20 -0
- package/examples/claude-code-usage.md +73 -0
- package/examples/custom-rules.md +98 -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 +12 -55
- package/package.json +54 -24
- package/plugins/claude-code/index.js +50 -0
- package/plugins/claude-code/rules/max-lines.js +52 -0
- package/plugins/hs-web-team/index.js +24 -0
- package/plugins/hs-web-team/rules/no-abbreviations.js +135 -0
- package/plugins/hs-web-team/rules/no-non-literal-constant-case.js +137 -0
- package/plugins/hs-web-team/rules/no-reduce-accumulator-copy.js +90 -0
- package/.githooks/commit-msg +0 -28
- package/.github/CODEOWNERS +0 -1
- package/.github/workflows/main.yml +0 -24
- package/.github/workflows/pr.yml +0 -24
- package/.github/workflows/release-please.yml +0 -44
- package/.nvmrc +0 -1
- package/.release-please-manifest.json +0 -3
- package/CHANGELOG.md +0 -23
- package/CLAUDE.md +0 -145
- package/release-please-config.json +0 -8
package/package.json
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hs-web-team/eslint-config-node",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "HubSpot Marketing WebTeam shared configurations for ESLint, Prettier, Stylelint, and Cypress",
|
|
5
|
+
"files": [
|
|
6
|
+
".prettierrc.json",
|
|
7
|
+
".stylelintrc.json",
|
|
8
|
+
"base-rules.js",
|
|
9
|
+
"bin/",
|
|
10
|
+
"browser.js",
|
|
11
|
+
"cypress.config.cjs",
|
|
12
|
+
"cypress.config.d.ts",
|
|
13
|
+
"cypress/",
|
|
14
|
+
"docs/",
|
|
15
|
+
"examples/",
|
|
16
|
+
"gherkin/",
|
|
17
|
+
"index.js",
|
|
18
|
+
"plugins/"
|
|
19
|
+
],
|
|
5
20
|
"main": "index.js",
|
|
6
21
|
"type": "module",
|
|
7
22
|
"exports": {
|
|
@@ -17,7 +32,10 @@
|
|
|
17
32
|
"./cypress": {
|
|
18
33
|
"types": "./cypress/index.d.ts",
|
|
19
34
|
"default": "./cypress/index.js"
|
|
20
|
-
}
|
|
35
|
+
},
|
|
36
|
+
"./plugin": "./plugins/hs-web-team/index.js",
|
|
37
|
+
"./gherkin/.gplintrc": "./gherkin/.gplintrc",
|
|
38
|
+
"./gherkin/coverage-config": "./gherkin/coverage-config.js"
|
|
21
39
|
},
|
|
22
40
|
"typesVersions": {
|
|
23
41
|
"*": {
|
|
@@ -31,15 +49,17 @@
|
|
|
31
49
|
},
|
|
32
50
|
"scripts": {
|
|
33
51
|
"postinstall": "node bin/check-peer-deps.cjs",
|
|
34
|
-
"lint": "npx eslint
|
|
35
|
-
"test": "
|
|
52
|
+
"lint": "npx eslint . --fix",
|
|
53
|
+
"test": "node --test",
|
|
54
|
+
"playground:build": "node playground/build.js",
|
|
36
55
|
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .githooks || true"
|
|
37
56
|
},
|
|
38
57
|
"engines": {
|
|
39
58
|
"node": ">=24"
|
|
40
59
|
},
|
|
41
60
|
"bin": {
|
|
42
|
-
"add-prettier": "bin/add-prettier-scripts.js"
|
|
61
|
+
"add-prettier": "bin/add-prettier-scripts.js",
|
|
62
|
+
"gherkin-tag-coverage": "bin/gherkin-tag-coverage.js"
|
|
43
63
|
},
|
|
44
64
|
"repository": {
|
|
45
65
|
"type": "git",
|
|
@@ -60,29 +80,34 @@
|
|
|
60
80
|
},
|
|
61
81
|
"homepage": "https://github.com/HubSpotWebTeam/wt-eslint-node#readme",
|
|
62
82
|
"dependencies": {
|
|
63
|
-
"@bahmutov/cypress-esbuild-preprocessor": "
|
|
64
|
-
"@
|
|
83
|
+
"@bahmutov/cypress-esbuild-preprocessor": "2.2.8",
|
|
84
|
+
"@cucumber/gherkin": "38.0.0",
|
|
85
|
+
"@cucumber/messages": "32.3.1",
|
|
86
|
+
"@eslint/eslintrc": "3.3.6",
|
|
65
87
|
"@eslint/js": "^9.39.1",
|
|
66
|
-
"@
|
|
67
|
-
"@typescript-eslint/
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"eslint
|
|
73
|
-
"eslint-
|
|
74
|
-
"eslint-plugin-
|
|
75
|
-
"eslint-plugin-react
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
88
|
+
"@eslint/markdown": "8.0.3",
|
|
89
|
+
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
|
90
|
+
"@typescript-eslint/parser": "^8.65.0",
|
|
91
|
+
"axe-core": "4.13.0",
|
|
92
|
+
"cypress-axe": "1.7.0",
|
|
93
|
+
"esbuild": "0.28.2",
|
|
94
|
+
"eslint": "9.39.5",
|
|
95
|
+
"eslint-formatter-checkstyle": "9.0.1",
|
|
96
|
+
"eslint-plugin-jsx-a11y": "6.10.2",
|
|
97
|
+
"eslint-plugin-react": "7.37.5",
|
|
98
|
+
"eslint-plugin-react-hooks": "7.1.1",
|
|
99
|
+
"globals": "17.11.0",
|
|
100
|
+
"gplint": "2.6.0",
|
|
101
|
+
"jiti": "2.7.0",
|
|
102
|
+
"js-yaml": "5.4.0",
|
|
103
|
+
"prettier": "3.9.6",
|
|
80
104
|
"prettier-plugin-gherkin": "4.0.0",
|
|
81
|
-
"
|
|
105
|
+
"strip-json-comments": "5.0.3",
|
|
106
|
+
"typescript-eslint": "8.68.0"
|
|
82
107
|
},
|
|
83
108
|
"peerDependencies": {
|
|
84
|
-
"@badeball/cypress-cucumber-preprocessor": "
|
|
85
|
-
"cypress": "
|
|
109
|
+
"@badeball/cypress-cucumber-preprocessor": "24.0.1",
|
|
110
|
+
"cypress": "15.21.0",
|
|
86
111
|
"stylelint": "^17.1.1",
|
|
87
112
|
"stylelint-config-standard": "^40.0.0",
|
|
88
113
|
"stylelint-config-standard-scss": "^17.0.0",
|
|
@@ -104,5 +129,10 @@
|
|
|
104
129
|
"cypress": {
|
|
105
130
|
"optional": true
|
|
106
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"
|
|
107
137
|
}
|
|
108
138
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import markdown from '@eslint/markdown';
|
|
2
|
+
import maxLines from './rules/max-lines.js';
|
|
3
|
+
|
|
4
|
+
// First-party ESLint plugin holding lint rules for Claude Code context files:
|
|
5
|
+
// `CLAUDE.md` project-context files and the `SKILL.md` files under
|
|
6
|
+
// `.claude/skills/`. Rules are referenced as `claude-code/<rule>` wherever the
|
|
7
|
+
// plugin is registered.
|
|
8
|
+
export const claudeCodePlugin = {
|
|
9
|
+
meta: { name: 'claude-code' },
|
|
10
|
+
rules: {
|
|
11
|
+
'max-lines': maxLines,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Self-reference via `configs.recommended`, following the same pattern as
|
|
16
|
+
// hsWebTeamPlugin (and typescript-eslint / eslint-plugin-react). It carries the
|
|
17
|
+
// flat-config blocks that scope the rules to the right files. Severity is `warn`
|
|
18
|
+
// so it never blocks; consumers can bump a rule to `error` or turn it `off`.
|
|
19
|
+
//
|
|
20
|
+
// `@eslint/markdown` supplies the `markdown/gfm` language — without it ESLint
|
|
21
|
+
// would try to parse these files as JavaScript and error.
|
|
22
|
+
//
|
|
23
|
+
// Skills carry more detail than a `CLAUDE.md`, so `SKILL.md` gets a higher
|
|
24
|
+
// (500-line) guideline than `CLAUDE.md` (100 lines), with tailored advice.
|
|
25
|
+
claudeCodePlugin.configs = {
|
|
26
|
+
recommended: [
|
|
27
|
+
{
|
|
28
|
+
files: ['**/CLAUDE.md'],
|
|
29
|
+
language: 'markdown/gfm',
|
|
30
|
+
plugins: { markdown, 'claude-code': claudeCodePlugin },
|
|
31
|
+
rules: {
|
|
32
|
+
'claude-code/max-lines': [
|
|
33
|
+
'warn',
|
|
34
|
+
{ max: 100, advice: 'Trim it or split sections into files Claude Code can import on demand.' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
files: ['**/.claude/skills/**/SKILL.md'],
|
|
40
|
+
language: 'markdown/gfm',
|
|
41
|
+
plugins: { markdown, 'claude-code': claudeCodePlugin },
|
|
42
|
+
rules: {
|
|
43
|
+
'claude-code/max-lines': [
|
|
44
|
+
'warn',
|
|
45
|
+
{ max: 500, advice: 'Trim it or move detail into reference files the skill loads on demand.' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const DEFAULT_MAX = 100;
|
|
2
|
+
const DEFAULT_ADVICE = 'Trim it or split sections into files Claude Code can import on demand.';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'suggestion',
|
|
7
|
+
docs: {
|
|
8
|
+
description: 'Warn when a doc file exceeds a maximum number of lines',
|
|
9
|
+
recommended: true,
|
|
10
|
+
},
|
|
11
|
+
schema: [
|
|
12
|
+
{
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
max: { type: 'integer', minimum: 1 },
|
|
16
|
+
advice: { type: 'string' },
|
|
17
|
+
},
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
messages: {
|
|
22
|
+
tooManyLines: '{{file}} has {{count}} lines, over the {{max}}-line guideline. {{advice}}',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
const { max = DEFAULT_MAX, advice = DEFAULT_ADVICE } = context.options[0] || {};
|
|
27
|
+
const { sourceCode } = context;
|
|
28
|
+
// Report against the file's basename so the message names the offending
|
|
29
|
+
// file (e.g. CLAUDE.md or SKILL.md) regardless of its directory.
|
|
30
|
+
const file = (context.filename || '').split('/').pop() || 'File';
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
root() {
|
|
34
|
+
const { lines } = sourceCode;
|
|
35
|
+
// `lines` splits on line breaks; a trailing newline yields a final
|
|
36
|
+
// empty entry that should not count as a content line.
|
|
37
|
+
const count = lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
|
|
38
|
+
|
|
39
|
+
if (count > max) {
|
|
40
|
+
context.report({
|
|
41
|
+
loc: {
|
|
42
|
+
start: { line: max + 1, column: 0 },
|
|
43
|
+
end: { line: lines.length, column: lines[lines.length - 1].length },
|
|
44
|
+
},
|
|
45
|
+
messageId: 'tooManyLines',
|
|
46
|
+
data: { file, count, max, advice },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { noAbbreviations } from './rules/no-abbreviations.js';
|
|
2
|
+
import { noNonLiteralConstantCase } from './rules/no-non-literal-constant-case.js';
|
|
3
|
+
import { noReduceAccumulatorCopy } from './rules/no-reduce-accumulator-copy.js';
|
|
4
|
+
|
|
5
|
+
export const hsWebTeamPlugin = {
|
|
6
|
+
rules: {
|
|
7
|
+
'no-abbreviations': noAbbreviations,
|
|
8
|
+
'no-non-literal-constant-case': noNonLiteralConstantCase,
|
|
9
|
+
'no-reduce-accumulator-copy': noReduceAccumulatorCopy,
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Self-reference allows the plugin to register itself via configs.recommended,
|
|
14
|
+
// following the same pattern as typescript-eslint and eslint-plugin-react.
|
|
15
|
+
hsWebTeamPlugin.configs = {
|
|
16
|
+
recommended: {
|
|
17
|
+
plugins: { 'hs-web-team': hsWebTeamPlugin },
|
|
18
|
+
rules: {
|
|
19
|
+
'hs-web-team/no-abbreviations': 'warn',
|
|
20
|
+
'hs-web-team/no-non-literal-constant-case': 'warn',
|
|
21
|
+
'hs-web-team/no-reduce-accumulator-copy': 'error',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const DEFAULT_EXCEPTIONS = new Set(['e', '_']);
|
|
2
|
+
const DEFAULT_MIN_LENGTH = 2;
|
|
3
|
+
|
|
4
|
+
const SORT_METHODS = new Set(['sort', 'toSorted']);
|
|
5
|
+
const ARRAY_ITERATION_METHODS = new Set([
|
|
6
|
+
'map',
|
|
7
|
+
'flatMap',
|
|
8
|
+
'forEach',
|
|
9
|
+
'filter',
|
|
10
|
+
'find',
|
|
11
|
+
'findIndex',
|
|
12
|
+
'findLast',
|
|
13
|
+
'findLastIndex',
|
|
14
|
+
'some',
|
|
15
|
+
'every',
|
|
16
|
+
// reduce/reduceRight are intentionally excluded: their callback signature is
|
|
17
|
+
// (accumulator, currentValue, currentIndex, …), so position 1 is the current
|
|
18
|
+
// *value*, not the index. The i-at-position-1 heuristic does not apply.
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns true if the VariableDeclarator is in the init of a for/for...of/for...in statement.
|
|
23
|
+
* Walks the parent chain directly — no scope API needed.
|
|
24
|
+
*
|
|
25
|
+
* @param {import('eslint').Rule.Node} declaratorNode - VariableDeclarator node
|
|
26
|
+
*/
|
|
27
|
+
function isDeclaredInForLoopInit(declaratorNode) {
|
|
28
|
+
const declaration = declaratorNode.parent; // VariableDeclaration
|
|
29
|
+
const enclosing = declaration?.parent;
|
|
30
|
+
if (!enclosing) return false;
|
|
31
|
+
return (
|
|
32
|
+
(enclosing.type === 'ForStatement' && enclosing.init === declaration) ||
|
|
33
|
+
enclosing.type === 'ForInStatement' ||
|
|
34
|
+
enclosing.type === 'ForOfStatement'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns true if funcNode is a direct inline callback to a method whose name is in methodNames.
|
|
40
|
+
* Only matches non-computed method calls (arr.sort(...), not arr['sort'](...)).
|
|
41
|
+
*
|
|
42
|
+
* @param {import('eslint').Rule.Node} funcNode - ArrowFunctionExpression or FunctionExpression
|
|
43
|
+
* @param {Set<string>} methodNames
|
|
44
|
+
*/
|
|
45
|
+
function isCallbackToMethod(funcNode, methodNames) {
|
|
46
|
+
const callExpr = funcNode.parent;
|
|
47
|
+
if (callExpr?.type !== 'CallExpression') return false;
|
|
48
|
+
if (!callExpr.arguments.includes(funcNode)) return false;
|
|
49
|
+
const { callee } = callExpr;
|
|
50
|
+
return (
|
|
51
|
+
callee?.type === 'MemberExpression' &&
|
|
52
|
+
!callee.computed &&
|
|
53
|
+
methodNames.has(callee.property.name)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
58
|
+
export const noAbbreviations = {
|
|
59
|
+
meta: {
|
|
60
|
+
type: 'suggestion',
|
|
61
|
+
docs: {
|
|
62
|
+
description: 'Disallow abbreviated identifier names (e.g. `w` instead of `warnings`)',
|
|
63
|
+
url: 'https://docs.hubwt.com/docs/developers/coding-on-the-web-team/coding-standards/javascript-standards/#avoid-abbreviation',
|
|
64
|
+
recommended: true,
|
|
65
|
+
},
|
|
66
|
+
messages: {
|
|
67
|
+
tooShort:
|
|
68
|
+
'Identifier "{{name}}" is too short ({{length}} < {{min}}). Use a descriptive name.',
|
|
69
|
+
},
|
|
70
|
+
schema: [
|
|
71
|
+
{
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
exceptions: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
75
|
+
minLength: { type: 'integer', minimum: 1 },
|
|
76
|
+
},
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
create(context) {
|
|
83
|
+
const options = context.options[0] ?? {};
|
|
84
|
+
const exceptions = new Set([...DEFAULT_EXCEPTIONS, ...(options.exceptions ?? [])]);
|
|
85
|
+
const minLength = options.minLength ?? DEFAULT_MIN_LENGTH;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
Identifier(node) {
|
|
89
|
+
const { name, parent } = node;
|
|
90
|
+
|
|
91
|
+
if (name.length >= minLength) return;
|
|
92
|
+
if (exceptions.has(name)) return;
|
|
93
|
+
|
|
94
|
+
// Only flag declaration sites — not usage sites.
|
|
95
|
+
//
|
|
96
|
+
// Object destructuring shorthand (const { w } = response) is intentionally
|
|
97
|
+
// excluded: `w` may be an external API property name the developer doesn't
|
|
98
|
+
// control. The idiomatic fix — const { w: warning } = response — is correct
|
|
99
|
+
// but non-obvious enough to leave as opt-in. Array destructuring IS included
|
|
100
|
+
// because the element binding name is always chosen by the developer.
|
|
101
|
+
const isDeclaration =
|
|
102
|
+
(parent.type === 'VariableDeclarator' && parent.id === node) ||
|
|
103
|
+
(parent.type === 'ArrayPattern' && parent.elements.includes(node)) ||
|
|
104
|
+
(parent.type === 'FunctionDeclaration' && parent.id === node) ||
|
|
105
|
+
(parent.type === 'FunctionDeclaration' && parent.params.includes(node)) ||
|
|
106
|
+
(parent.type === 'ArrowFunctionExpression' && parent.params.includes(node)) ||
|
|
107
|
+
(parent.type === 'FunctionExpression' && parent.params.includes(node));
|
|
108
|
+
|
|
109
|
+
if (!isDeclaration) return;
|
|
110
|
+
|
|
111
|
+
// For-loop variables (i, j, k…) are exempt regardless of length.
|
|
112
|
+
if (parent.type === 'VariableDeclarator' && isDeclaredInForLoopInit(parent)) return;
|
|
113
|
+
|
|
114
|
+
// a and b are conventional sort-comparator names — exempt only inside .sort()/.toSorted().
|
|
115
|
+
if ((name === 'a' || name === 'b') && isCallbackToMethod(parent, SORT_METHODS)) return;
|
|
116
|
+
|
|
117
|
+
// i is a conventional index name — exempt only when it is the index param (position 1)
|
|
118
|
+
// of an array iteration callback: items.map((item, i) => ...).
|
|
119
|
+
// Position 1 is the index slot for every array iteration method except reduce/reduceRight
|
|
120
|
+
// (where it's position 2), but naming the reduce index `i` is uncommon enough to skip.
|
|
121
|
+
if (
|
|
122
|
+
name === 'i' &&
|
|
123
|
+
parent.params?.indexOf(node) === 1 &&
|
|
124
|
+
isCallbackToMethod(parent, ARRAY_ITERATION_METHODS)
|
|
125
|
+
) return;
|
|
126
|
+
|
|
127
|
+
context.report({
|
|
128
|
+
node,
|
|
129
|
+
messageId: 'tooShort',
|
|
130
|
+
data: { name, length: name.length, min: minLength },
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const RECOMMENDATION =
|
|
2
|
+
'Prefer a for...of loop with in-place mutation, or a purpose-built method (flatMap, Object.groupBy, flat) if it fits your pattern.';
|
|
3
|
+
|
|
4
|
+
function isReduceCall(node) {
|
|
5
|
+
if (!node) return false;
|
|
6
|
+
|
|
7
|
+
const callee = node.callee;
|
|
8
|
+
if (!callee || !callee.property || !callee.property.name) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
callee.property.name === 'reduce' || callee.property.name === 'reduceRight'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isReduceAccumulator(def) {
|
|
18
|
+
return (
|
|
19
|
+
def &&
|
|
20
|
+
def.type === 'Parameter' &&
|
|
21
|
+
def.node.parent &&
|
|
22
|
+
isReduceCall(def.node.parent) &&
|
|
23
|
+
def.node.params &&
|
|
24
|
+
def.node.params.length &&
|
|
25
|
+
// def.name is the Identifier AST node for the definition; this identity
|
|
26
|
+
// check confirms it is the first parameter (the accumulator), not a later one.
|
|
27
|
+
def.name === def.node.params[0]
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const noReduceAccumulatorCopy = {
|
|
32
|
+
meta: {
|
|
33
|
+
type: 'problem',
|
|
34
|
+
docs: {
|
|
35
|
+
description:
|
|
36
|
+
'Disallow O(n²) accumulator copies (spread, concat) inside reduce/reduceRight callbacks',
|
|
37
|
+
recommended: true,
|
|
38
|
+
},
|
|
39
|
+
messages: {
|
|
40
|
+
AccumulatorSpread: `Spreading the reduce accumulator is O(n²). ${RECOMMENDATION}`,
|
|
41
|
+
AccumulatorShallowCopy: `Calling .{{ method }}() on the reduce accumulator is O(n²). ${RECOMMENDATION}`,
|
|
42
|
+
},
|
|
43
|
+
schema: [],
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
create(context) {
|
|
47
|
+
const { sourceCode } = context;
|
|
48
|
+
|
|
49
|
+
function checkIsAccumulatorSpread(node) {
|
|
50
|
+
if (!node.argument || node.argument.type !== 'Identifier') return;
|
|
51
|
+
|
|
52
|
+
const scope = sourceCode.getScope(node);
|
|
53
|
+
const spreadee = scope.set.get(node.argument.name);
|
|
54
|
+
const spreadeeDef =
|
|
55
|
+
spreadee && spreadee.defs && spreadee.defs.length && spreadee.defs[0];
|
|
56
|
+
|
|
57
|
+
if (isReduceAccumulator(spreadeeDef)) {
|
|
58
|
+
context.report({ node, messageId: 'AccumulatorSpread' });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function checkIsAccumulatorShallowCopy(node) {
|
|
63
|
+
if (
|
|
64
|
+
node.callee.type !== 'MemberExpression' ||
|
|
65
|
+
!node.callee.property ||
|
|
66
|
+
!node.callee.object ||
|
|
67
|
+
node.callee.object.type !== 'Identifier'
|
|
68
|
+
) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const method = node.callee.property.name;
|
|
73
|
+
if (method !== 'concat') return;
|
|
74
|
+
|
|
75
|
+
const scope = sourceCode.getScope(node);
|
|
76
|
+
const variable = scope.set.get(node.callee.object.name);
|
|
77
|
+
const definition =
|
|
78
|
+
variable && variable.defs && variable.defs.length && variable.defs[0];
|
|
79
|
+
|
|
80
|
+
if (isReduceAccumulator(definition)) {
|
|
81
|
+
context.report({ node, messageId: 'AccumulatorShallowCopy', data: { method } });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
SpreadElement: checkIsAccumulatorSpread,
|
|
87
|
+
CallExpression: checkIsAccumulatorShallowCopy,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
package/.githooks/commit-msg
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
|
|
3
|
-
commit_msg=$(head -1 "$1")
|
|
4
|
-
|
|
5
|
-
# Skip merge and revert commits (auto-generated messages)
|
|
6
|
-
case "$commit_msg" in
|
|
7
|
-
Merge\ *|Revert\ *) exit 0 ;;
|
|
8
|
-
esac
|
|
9
|
-
|
|
10
|
-
pattern="^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-zA-Z0-9_/-]+\))?!?: .+"
|
|
11
|
-
|
|
12
|
-
if ! echo "$commit_msg" | grep -qE "$pattern"; then
|
|
13
|
-
echo ""
|
|
14
|
-
echo "ERROR: Commit message does not follow Conventional Commits format."
|
|
15
|
-
echo ""
|
|
16
|
-
echo " Expected: type(optional-scope): description"
|
|
17
|
-
echo " Types: feat | fix | docs | style | refactor | test | chore | ci | perf | revert | build"
|
|
18
|
-
echo ""
|
|
19
|
-
echo " Examples:"
|
|
20
|
-
echo " feat: add new rule for import order"
|
|
21
|
-
echo " fix: correct comma-dangle config for browser"
|
|
22
|
-
echo " feat!: drop support for Node 18 (breaking change -> major bump)"
|
|
23
|
-
echo " feat(browser): add React 19 support"
|
|
24
|
-
echo ""
|
|
25
|
-
echo " See: https://www.conventionalcommits.org"
|
|
26
|
-
echo ""
|
|
27
|
-
exit 1
|
|
28
|
-
fi
|
package/.github/CODEOWNERS
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
* @HubSpotWebTeam/webteamdevs
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
name: Merge to Main
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
workflow_dispatch:
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
build:
|
|
10
|
-
# The type of runner that the job will run on
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
strategy:
|
|
13
|
-
matrix:
|
|
14
|
-
node: [24]
|
|
15
|
-
|
|
16
|
-
name: Node ${{ matrix.node }} lint
|
|
17
|
-
steps:
|
|
18
|
-
- uses: actions/checkout@v6
|
|
19
|
-
- name: Run unit tests
|
|
20
|
-
uses: actions/setup-node@v6
|
|
21
|
-
with:
|
|
22
|
-
node-version: ${{ matrix.node }}
|
|
23
|
-
- run: npm install
|
|
24
|
-
- run: npm run lint
|