@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/.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
|
],
|
|
@@ -50,7 +50,10 @@
|
|
|
50
50
|
}
|
|
51
51
|
],
|
|
52
52
|
"selector-disallowed-list": [
|
|
53
|
-
[
|
|
53
|
+
[
|
|
54
|
+
"/(?<!:(?:is|not|where|matches)\\([^)]*)(?:^|[\\s>+~])\\s*\\.-[a-z]/i",
|
|
55
|
+
"/(?:^|[\\s>+~])\\s*:(?:is|not|where|matches)\\(\\s*\\.-[a-z]/i"
|
|
56
|
+
],
|
|
54
57
|
{
|
|
55
58
|
"message": "Modifier classes (starting with '-') must not be standalone selectors; always combine with a base class (e.g., .prefix-element.-modifier or &.-modifier in nested rules)",
|
|
56
59
|
"severity": "error"
|
package/README.md
CHANGED
|
@@ -10,7 +10,9 @@ 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)
|
|
15
|
+
- [Custom Rules](#custom-rules)
|
|
14
16
|
- [Where to use it](#where-to-use-it)
|
|
15
17
|
- [Using the Prettier Scripts](#using-the-prettier-scripts)
|
|
16
18
|
- [Contributing](#contributing)
|
|
@@ -125,6 +127,26 @@ This package provides shared Cypress configuration for E2E testing.
|
|
|
125
127
|
|
|
126
128
|
For detailed Cypress configuration and migration documentation, see [examples/cypress-usage.md](./examples/cypress-usage.md).
|
|
127
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
|
+
|
|
128
150
|
## Accessibility Testing (optional)
|
|
129
151
|
|
|
130
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.
|
|
@@ -158,6 +180,17 @@ This package includes a shared accessibility testing setup using [cypress-axe](h
|
|
|
158
180
|
|
|
159
181
|
`cy.checkAccessibility()` adds the `high-contrast` class to `body`, runs WCAG 2.2 Level AA rules only, and logs each violation with its id, help text, impact, element targets, and help URL. TypeScript types are included — no `tsconfig.json` changes required.
|
|
160
182
|
|
|
183
|
+
## Custom Rules
|
|
184
|
+
|
|
185
|
+
This package ships a `hs-web-team` ESLint plugin with custom rules that are active automatically when you use `wtConfig` or `wtBrowserConfig` — no extra setup needed.
|
|
186
|
+
|
|
187
|
+
| Rule | Severity | Summary |
|
|
188
|
+
|------|----------|---------|
|
|
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` |
|
|
191
|
+
|
|
192
|
+
For examples and remediation guidance, see [examples/custom-rules.md](./examples/custom-rules.md).
|
|
193
|
+
|
|
161
194
|
## Where to use it
|
|
162
195
|
|
|
163
196
|
This package provides multiple configurations:
|
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
|
+
};
|
package/bin/check-peer-deps.cjs
CHANGED
|
@@ -14,7 +14,7 @@ if (!projectRoot || projectRoot === path.resolve(__dirname, '..')) {
|
|
|
14
14
|
const ownPkg = require('../package.json');
|
|
15
15
|
|
|
16
16
|
const stylelintPeers = Object.entries(ownPkg.peerDependencies || {}).filter(([name]) =>
|
|
17
|
-
name.startsWith('stylelint')
|
|
17
|
+
name.startsWith('stylelint'),
|
|
18
18
|
);
|
|
19
19
|
|
|
20
20
|
// Extracts the minimum required major version from a semver range like ^17.0.0 or >=17.1.1.
|
|
@@ -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,44 +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';
|
|
6
|
+
import { claudeCodePlugin } from './plugins/claude-code/index.js';
|
|
7
|
+
import { hsWebTeamPlugin } from './plugins/hs-web-team/index.js';
|
|
8
|
+
import { underscoreDangleAllowlist, browserBaseRules as baseRules, browserGlobals } from './base-rules.js';
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
const baseRules = {
|
|
10
|
-
'comma-dangle': ['warn', 'always-multiline'],
|
|
11
|
-
'no-param-reassign': ['warn', { props: false }],
|
|
12
|
-
'arrow-parens': 0,
|
|
13
|
-
'no-plusplus': 0,
|
|
14
|
-
'no-console': ['error', { allow: ['warn', 'error'] }],
|
|
15
|
-
'no-confusing-arrow': 0,
|
|
16
|
-
'no-underscore-dangle': [
|
|
17
|
-
'error',
|
|
18
|
-
{
|
|
19
|
-
allow: [
|
|
20
|
-
'_hsg',
|
|
21
|
-
'_hsq',
|
|
22
|
-
],
|
|
23
|
-
},
|
|
24
|
-
],
|
|
25
|
-
'no-trailing-spaces': ['error', { skipBlankLines: true }],
|
|
26
|
-
'no-unused-expressions': ['warn', { allowTernary: true }],
|
|
27
|
-
'max-len': [
|
|
28
|
-
2,
|
|
29
|
-
{
|
|
30
|
-
code: 120,
|
|
31
|
-
ignoreStrings: true,
|
|
32
|
-
ignoreTemplateLiterals: true,
|
|
33
|
-
},
|
|
34
|
-
],
|
|
35
|
-
'operator-linebreak': 0,
|
|
36
|
-
'implicit-arrow-linebreak': 0,
|
|
37
|
-
indent: 0,
|
|
38
|
-
'object-curly-newline': 0,
|
|
39
|
-
'function-paren-newline': 0,
|
|
40
|
-
'nonblock-statement-body-position': 0,
|
|
41
|
-
};
|
|
10
|
+
export { underscoreDangleAllowlist };
|
|
42
11
|
|
|
43
12
|
// React-specific rules
|
|
44
13
|
const reactRules = {
|
|
@@ -57,36 +26,22 @@ const reactRules = {
|
|
|
57
26
|
};
|
|
58
27
|
|
|
59
28
|
// Common ignore patterns
|
|
60
|
-
const commonIgnores = [
|
|
61
|
-
'**/node_modules/**',
|
|
62
|
-
'**/dist/**',
|
|
63
|
-
'**/build/**',
|
|
64
|
-
'**/.next/**',
|
|
65
|
-
'**/coverage/**',
|
|
66
|
-
'eslint.config.js',
|
|
67
|
-
];
|
|
29
|
+
const commonIgnores = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/coverage/**'];
|
|
68
30
|
|
|
69
31
|
export default [
|
|
70
32
|
// Global ignores
|
|
71
33
|
{
|
|
72
34
|
ignores: commonIgnores,
|
|
73
35
|
},
|
|
74
|
-
// Base
|
|
75
|
-
|
|
36
|
+
// Base recommended rules — scoped to JS/TS so they never run against
|
|
37
|
+
// non-JS languages (e.g. the markdown used for CLAUDE.md below).
|
|
38
|
+
{ ...js.configs.recommended, files: ['**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'] },
|
|
76
39
|
{
|
|
77
40
|
files: ['**/*.{js,mjs,cjs,jsx}'],
|
|
78
41
|
languageOptions: {
|
|
79
42
|
ecmaVersion: 2021,
|
|
80
43
|
sourceType: 'module',
|
|
81
|
-
globals:
|
|
82
|
-
...globals.browser,
|
|
83
|
-
...globals.es2021,
|
|
84
|
-
...globals.jest,
|
|
85
|
-
// Custom browser globals from original config
|
|
86
|
-
$: true,
|
|
87
|
-
jQuery: true,
|
|
88
|
-
Invoca: true,
|
|
89
|
-
},
|
|
44
|
+
globals: browserGlobals,
|
|
90
45
|
parserOptions: {
|
|
91
46
|
ecmaFeatures: {
|
|
92
47
|
jsx: true,
|
|
@@ -117,6 +72,7 @@ export default [
|
|
|
117
72
|
...reactRules,
|
|
118
73
|
},
|
|
119
74
|
},
|
|
75
|
+
hsWebTeamPlugin.configs.recommended,
|
|
120
76
|
// TypeScript config
|
|
121
77
|
...tseslint.configs.recommended.map(config => ({
|
|
122
78
|
...config,
|
|
@@ -127,14 +83,7 @@ export default [
|
|
|
127
83
|
languageOptions: {
|
|
128
84
|
ecmaVersion: 2021,
|
|
129
85
|
sourceType: 'module',
|
|
130
|
-
globals:
|
|
131
|
-
...globals.browser,
|
|
132
|
-
...globals.es2021,
|
|
133
|
-
...globals.jest,
|
|
134
|
-
$: true,
|
|
135
|
-
jQuery: true,
|
|
136
|
-
Invoca: true,
|
|
137
|
-
},
|
|
86
|
+
globals: browserGlobals,
|
|
138
87
|
parserOptions: {
|
|
139
88
|
ecmaFeatures: {
|
|
140
89
|
jsx: true,
|
|
@@ -165,4 +114,6 @@ export default [
|
|
|
165
114
|
...reactRules,
|
|
166
115
|
},
|
|
167
116
|
},
|
|
117
|
+
// CLAUDE.md and SKILL.md context-file checks (see plugins/claude-code)
|
|
118
|
+
...claudeCodePlugin.configs.recommended,
|
|
168
119
|
];
|
package/cypress.config.cjs
CHANGED
|
@@ -22,13 +22,13 @@ const envs = {
|
|
|
22
22
|
* the directory where the hubspot.config.yml file is located.
|
|
23
23
|
*
|
|
24
24
|
* @param {string} currDir - the current working directory path to search from
|
|
25
|
-
* @returns {string} The absolute path of the project's root directory
|
|
25
|
+
* @returns {string|null} The absolute path of the project's root directory, or null if not found
|
|
26
26
|
*/
|
|
27
27
|
const getRootDir = currDir => {
|
|
28
28
|
if (fs.existsSync(path.join(currDir, 'hubspot.config.yml'))) return currDir;
|
|
29
29
|
const parentDir = path.dirname(currDir);
|
|
30
30
|
if (parentDir === currDir) {
|
|
31
|
-
|
|
31
|
+
return null;
|
|
32
32
|
}
|
|
33
33
|
return getRootDir(parentDir);
|
|
34
34
|
};
|
|
@@ -39,19 +39,21 @@ const getRootDir = currDir => {
|
|
|
39
39
|
*/
|
|
40
40
|
const getDevBaseUrl = () => {
|
|
41
41
|
try {
|
|
42
|
-
global.console.log(
|
|
43
|
-
'To test a dev URL, add the `baseUrl` property to your `DEV` portal configuration in `hubspot.config.yml`',
|
|
44
|
-
);
|
|
45
|
-
|
|
46
42
|
const root = getRootDir(__dirname);
|
|
43
|
+
if (!root) return null;
|
|
47
44
|
const configPath = path.resolve(root, 'hubspot.config.yml');
|
|
48
45
|
const config = fs.readFileSync(configPath, 'utf8');
|
|
49
46
|
const { portals } = yaml.load(config);
|
|
50
47
|
const devPortal = portals.find(portal => portal.name === 'DEV');
|
|
51
48
|
const devBaseUrl = devPortal.baseUrl;
|
|
49
|
+
if (!devBaseUrl) {
|
|
50
|
+
global.console.log(
|
|
51
|
+
'To test a dev URL, add the `baseUrl` property to your `DEV` portal configuration in `hubspot.config.yml`',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
52
54
|
return devBaseUrl || null;
|
|
53
55
|
} catch (error) {
|
|
54
|
-
global.console.
|
|
56
|
+
global.console.log('Could not read DEV baseUrl from hubspot.config.yml:', error.message);
|
|
55
57
|
return null;
|
|
56
58
|
}
|
|
57
59
|
};
|
|
@@ -90,10 +92,7 @@ async function setupNodeEvents(on, config) {
|
|
|
90
92
|
await addCucumberPreprocessorPlugin(on, config);
|
|
91
93
|
|
|
92
94
|
// Use esbuild for fast TypeScript and feature file processing
|
|
93
|
-
on(
|
|
94
|
-
'file:preprocessor',
|
|
95
|
-
createBundler({ plugins: [createEsbuildPlugin(config)] }),
|
|
96
|
-
);
|
|
95
|
+
on('file:preprocessor', createBundler({ plugins: [createEsbuildPlugin(config)] }));
|
|
97
96
|
|
|
98
97
|
return config;
|
|
99
98
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# 1. Lint CLAUDE.md with an ESLint rule
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-08-19
|
|
5
|
+
- Deciders: HubSpot Web Team
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
`CLAUDE.md` gives Claude Code the project context it needs to be useful in a
|
|
10
|
+
repo: conventions, common commands, and gotchas. One failure mode quietly
|
|
11
|
+
degrades that context: the file **grows too large**. Oversized context files
|
|
12
|
+
are harder for Claude Code to use effectively and tend to accumulate stale or
|
|
13
|
+
redundant guidance. We treat **100 lines** as the practical guideline.
|
|
14
|
+
|
|
15
|
+
An earlier prototype enforced this as a warn-only check in a shared **git
|
|
16
|
+
pre-push hook** (HubSpotMarketingWebTeam/shared-githooks PR #35, recorded in
|
|
17
|
+
that repo's ADR 0001). During review the point was raised that a check on a
|
|
18
|
+
file's content — line count, banned boilerplate, deprecated constructs — is
|
|
19
|
+
conceptually a **linting** concern: deterministic, per-file, and something each
|
|
20
|
+
project should be able to tune or disable. This package
|
|
21
|
+
(`@hs-web-team/eslint-config-node`) is the shared home for exactly those rules,
|
|
22
|
+
already installed as a dev dependency across the team's projects, so it is the
|
|
23
|
+
natural place for the check to live.
|
|
24
|
+
|
|
25
|
+
This ADR supersedes the pre-push-hook approach. The `shared-githooks` ADR 0001
|
|
26
|
+
should be marked superseded and its `CLAUDE.md` check removed.
|
|
27
|
+
|
|
28
|
+
## Decision
|
|
29
|
+
|
|
30
|
+
Add a first-party ESLint plugin, `claude-code`, to this package and wire it
|
|
31
|
+
**always-on** into both shared configs (`index.js` for Node, `browser.js` for
|
|
32
|
+
browser/React). The plugin ships one rule:
|
|
33
|
+
|
|
34
|
+
- **`claude-code/max-lines`** — warns when `CLAUDE.md` exceeds a maximum number
|
|
35
|
+
of lines. Default `max` is **100**; a trailing newline does not count as a
|
|
36
|
+
content line. It reports at the first line past the limit.
|
|
37
|
+
|
|
38
|
+
Wiring (see `claude-code.config.js`):
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
{
|
|
42
|
+
files: ['**/CLAUDE.md'],
|
|
43
|
+
language: 'markdown/gfm',
|
|
44
|
+
plugins: { markdown, 'claude-code': claudeCode },
|
|
45
|
+
rules: { 'claude-code/max-lines': ['warn', { max: 100 }] },
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Properties:
|
|
50
|
+
|
|
51
|
+
- **Warn-only.** Severity is `warn`, so the rule never fails a lint run on its
|
|
52
|
+
own — consistent with the nudge-not-block philosophy of the original hook.
|
|
53
|
+
- **Always on, fully overridable.** Every project that extends either config
|
|
54
|
+
gets the check with no setup, and can retune (`{ max: 150 }`), promote
|
|
55
|
+
(`error`), or disable (`off`) it per project by scoping an override to
|
|
56
|
+
`files: ['**/CLAUDE.md']`.
|
|
57
|
+
- **Markdown language dependency.** ESLint cannot parse `.md` on its own — it
|
|
58
|
+
would feed `CLAUDE.md` to the JavaScript parser and error. We add
|
|
59
|
+
`@eslint/markdown`, which registers the `markdown/gfm` language used to parse
|
|
60
|
+
the file into an AST the rule can inspect.
|
|
61
|
+
- **Scoped base rules.** `js.configs.recommended` was previously registered
|
|
62
|
+
with no `files` key, so it applied to *every* linted file. Once `CLAUDE.md`
|
|
63
|
+
became lintable, its recommended JS rules (e.g. `no-irregular-whitespace`)
|
|
64
|
+
ran against the markdown AST and crashed. We scoped that config to
|
|
65
|
+
`**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}`. This is a no-op for existing
|
|
66
|
+
consumers — non-JS files were never lintable before — but it is a subtle
|
|
67
|
+
change worth recording.
|
|
68
|
+
|
|
69
|
+
## Considered options
|
|
70
|
+
|
|
71
|
+
**Enforcement mechanism — ESLint rule vs. git pre-push hook.** We chose the
|
|
72
|
+
**ESLint rule**. A file-content check is a linting concern, and expressing it as
|
|
73
|
+
a rule makes it per-project overridable — the property the hook lacked. The
|
|
74
|
+
hook's advantages were real (it fires for every push regardless of a repo's
|
|
75
|
+
lint setup, and can surface a desktop notification to GUI git clients), but they
|
|
76
|
+
did not outweigh living in the tool built for opinionated, tunable file rules.
|
|
77
|
+
|
|
78
|
+
**Missing-`CLAUDE.md` check — keep vs. drop.** We **dropped** it. ESLint only
|
|
79
|
+
runs rules against files that exist and match, so "this repo has no `CLAUDE.md`"
|
|
80
|
+
cannot be expressed as a rule. The prototype hook warned on a missing file; that
|
|
81
|
+
capability does not survive the move and we accepted the loss rather than keep a
|
|
82
|
+
hook alongside the rule.
|
|
83
|
+
|
|
84
|
+
**Rule set — which checks to ship.** Review suggested three deterministic
|
|
85
|
+
checks: max lines, banned boilerplate/placeholder text, and flagging deprecated
|
|
86
|
+
"commands" in favour of skills. We shipped **`max-lines` only**. It is the
|
|
87
|
+
objective, high-signal check that maps directly to the context-bloat concern. A
|
|
88
|
+
`no-placeholder-text` rule was prototyped and dropped (YAGNI): matching a single
|
|
89
|
+
canonical phrase is a weak proxy for quality — false positives for files that
|
|
90
|
+
keep a generated opener but are well-customised, false negatives for genuinely
|
|
91
|
+
empty files that reword it — with no evidence yet of a recurring problem. The
|
|
92
|
+
rule structure makes adding more checks later cheap.
|
|
93
|
+
|
|
94
|
+
**Delivery — always-on in the default config vs. opt-in export.** We chose
|
|
95
|
+
**always-on**. Because the rule is warn-only and overridable, the cost to a
|
|
96
|
+
project that does not want it is one line in `eslint.config.js`; that does not
|
|
97
|
+
justify an opt-in export that most projects would have to remember to add.
|
|
98
|
+
|
|
99
|
+
## Consequences
|
|
100
|
+
|
|
101
|
+
- Projects extending either config will see a warning when their `CLAUDE.md`
|
|
102
|
+
exceeds 100 lines. Lint runs still pass (warnings only).
|
|
103
|
+
- **The rule only fires if the project's lint command actually includes
|
|
104
|
+
`CLAUDE.md` in its file set.** `eslint .` picks it up automatically; a
|
|
105
|
+
narrowed command such as `eslint src/**/*.ts` does not. Projects that lint a
|
|
106
|
+
subset must add `CLAUDE.md` (or `**/CLAUDE.md`) to the set. This is weaker,
|
|
107
|
+
less guaranteed coverage than the always-on hook, and it depends on projects
|
|
108
|
+
being on ESLint 9 flat config.
|
|
109
|
+
- There is no longer any warning for a **missing** `CLAUDE.md`.
|
|
110
|
+
- Adds `@eslint/markdown` as a runtime dependency of this package (it installs
|
|
111
|
+
into consumers' dev dependency tree via the config).
|
|
112
|
+
- Establishes `docs/adr/` and the ADR format for this repository.
|
|
113
|
+
- This package's own `CLAUDE.md` (currently ~145 lines) now warns against the
|
|
114
|
+
rule; trimming it is tracked separately.
|
|
115
|
+
|
|
116
|
+
## Future work (not yet implemented)
|
|
117
|
+
|
|
118
|
+
- **More `CLAUDE.md` rules.** The plugin can grow additional checks as concrete
|
|
119
|
+
needs appear (e.g. flagging deprecated constructs). Each should stay warn-only
|
|
120
|
+
and overridable.
|
|
121
|
+
- **Shared, reusable config export.** If more `CLAUDE.md` rules land, consider
|
|
122
|
+
exposing them as a named `claude-code` config object so projects can opt into or
|
|
123
|
+
out of the whole group in one place.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# 2. Extend max-lines to SKILL.md and consolidate the claude-code plugin
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-08-25
|
|
5
|
+
- Deciders: HubSpot Web Team
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
[ADR 0001](0001-lint-claude-md-with-eslint.md) added the first-party
|
|
10
|
+
`claude-code` ESLint plugin with a single `max-lines` rule targeting
|
|
11
|
+
`CLAUDE.md`, and named two follow-ons in its *Future work* section: more
|
|
12
|
+
`claude-code` rules/targets, and exposing the wiring as a reusable named config
|
|
13
|
+
object. This ADR records both landing together.
|
|
14
|
+
|
|
15
|
+
Claude Code skills live under `.claude/skills/`, each described by a `SKILL.md`
|
|
16
|
+
instruction file. The same context-bloat failure mode that motivated the
|
|
17
|
+
`CLAUDE.md` check applies to skills: an oversized `SKILL.md` is harder for
|
|
18
|
+
Claude Code to use and tends to accumulate stale guidance. Skills legitimately
|
|
19
|
+
carry more detail than a `CLAUDE.md`, so they warrant a **higher** guideline
|
|
20
|
+
rather than the same 100-line limit.
|
|
21
|
+
|
|
22
|
+
Separately, ADR 0001 wired the plugin through a root-level `claude-code.config.js`
|
|
23
|
+
that exported the flat-config blocks, while the sibling `hs-web-team` plugin was
|
|
24
|
+
already self-contained under `plugins/hs-web-team/` and exposed its config via
|
|
25
|
+
`configs.recommended` (the pattern typescript-eslint and eslint-plugin-react
|
|
26
|
+
use). Two plugins, two wiring styles, and a config file in the repo root.
|
|
27
|
+
|
|
28
|
+
## Decision
|
|
29
|
+
|
|
30
|
+
**1. Extend `max-lines` to `SKILL.md`.** The rule itself is file-agnostic — it
|
|
31
|
+
counts content lines of whatever the flat-config glob feeds it — so no counting
|
|
32
|
+
logic changed. A second flat-config block targets
|
|
33
|
+
`**/.claude/skills/**/SKILL.md` with `max: 500`. The `CLAUDE.md` block keeps its
|
|
34
|
+
100-line guideline. Per-type thresholds live in the config blocks, not baked
|
|
35
|
+
into the rule (its internal `DEFAULT_MAX` stays 100 as a fallback).
|
|
36
|
+
|
|
37
|
+
**2. Add an `advice` rule option for per-type messaging.** The rule message is
|
|
38
|
+
now `{{file}} has {{count}} lines, over the {{max}}-line guideline. {{advice}}`,
|
|
39
|
+
where `{{file}}` is the offending file's basename (from `context.filename`) and
|
|
40
|
+
`{{advice}}` is a configurable string. Each block supplies tailored guidance:
|
|
41
|
+
|
|
42
|
+
- `CLAUDE.md` — "Trim it or split sections into files Claude Code can import on
|
|
43
|
+
demand."
|
|
44
|
+
- `SKILL.md` — "Trim it or move detail into reference files the skill loads on
|
|
45
|
+
demand."
|
|
46
|
+
|
|
47
|
+
`advice` defaults to the original `CLAUDE.md` wording, so the change is
|
|
48
|
+
backward-compatible for any consumer overriding the rule without it.
|
|
49
|
+
|
|
50
|
+
**3. Consolidate the plugin to match `hs-web-team`.** The root
|
|
51
|
+
`claude-code.config.js` is removed. The plugin (`plugins/claude-code/index.js`)
|
|
52
|
+
now exports a named `claudeCodePlugin` carrying both `rules` and
|
|
53
|
+
`configs.recommended` — an array of the two flat-config blocks. `index.js` and
|
|
54
|
+
`browser.js` consume it with `...claudeCodePlugin.configs.recommended`. This is
|
|
55
|
+
the reusable-config-object follow-on named in ADR 0001, and it makes both
|
|
56
|
+
first-party plugins self-contained under `plugins/<name>/` with one wiring style.
|
|
57
|
+
|
|
58
|
+
Both blocks remain **warn-only** and **always-on**, fully overridable per
|
|
59
|
+
project by scoping to the relevant glob — unchanged from ADR 0001.
|
|
60
|
+
|
|
61
|
+
## Considered options
|
|
62
|
+
|
|
63
|
+
**New rule vs. reuse `max-lines`.** We **reused** the rule. Line-counting is
|
|
64
|
+
identical for both file types; only the threshold and advice differ, which
|
|
65
|
+
flat-config blocks and the `advice` option express cleanly. A separate rule
|
|
66
|
+
would only be justified by checks of a different *kind* (e.g. validating
|
|
67
|
+
`SKILL.md` frontmatter), which we did not need.
|
|
68
|
+
|
|
69
|
+
**Generic message vs. per-type advice.** We chose **per-type advice** via a rule
|
|
70
|
+
option. A single generic message would have been simpler but would give
|
|
71
|
+
misleading guidance (the `CLAUDE.md` "import on demand" wording does not fit a
|
|
72
|
+
skill). Naming the file via `context.filename` and letting each block supply its
|
|
73
|
+
own advice keeps one rule while producing accurate messages for both targets.
|
|
74
|
+
|
|
75
|
+
**SKILL.md glob scope.** We scoped to `**/.claude/skills/**/SKILL.md` only — the
|
|
76
|
+
standard skills location — rather than also matching a bare top-level
|
|
77
|
+
`skills/**/SKILL.md`. Narrower is safer; it can widen later if a need appears.
|
|
78
|
+
|
|
79
|
+
**Keep root config file vs. fold into the plugin.** We **folded** it in to match
|
|
80
|
+
`hs-web-team`. One wiring convention across first-party plugins is easier to
|
|
81
|
+
reason about, and it removes a root-level file whose only consumers were
|
|
82
|
+
`index.js`/`browser.js`.
|
|
83
|
+
|
|
84
|
+
## Consequences
|
|
85
|
+
|
|
86
|
+
- Projects extending either config now also get a warning when a
|
|
87
|
+
`.claude/skills/**/SKILL.md` exceeds 500 lines, in addition to the existing
|
|
88
|
+
`CLAUDE.md` 100-line check. Still warnings only.
|
|
89
|
+
- As with `CLAUDE.md`, the `SKILL.md` check fires only if the project's lint
|
|
90
|
+
command includes those files in its set (`eslint .` does; a narrowed command
|
|
91
|
+
may not).
|
|
92
|
+
- The `advice` option is now part of the rule's public schema.
|
|
93
|
+
- `claude-code.config.js` no longer exists; anything importing
|
|
94
|
+
`claudeCodeConfig`/`claudeSkillsConfig` from it must instead import
|
|
95
|
+
`claudeCodePlugin` from `plugins/claude-code/index.js` and use
|
|
96
|
+
`configs.recommended`. Only this package's own `index.js`/`browser.js` did so.
|
|
97
|
+
- No change to the counting logic, the `markdown/gfm` dependency, or the
|
|
98
|
+
warn-only/always-on/overridable properties established in ADR 0001.
|
|
99
|
+
|
|
100
|
+
## Future work (not yet implemented)
|
|
101
|
+
|
|
102
|
+
- **`SKILL.md` structural checks.** If needed, a distinct rule could validate
|
|
103
|
+
skill frontmatter (`name`/`description`) or required sections — a different
|
|
104
|
+
kind of check than line-counting, so a new rule rather than another
|
|
105
|
+
`max-lines` block.
|
|
@@ -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:
|