@hs-web-team/eslint-config-node 4.2.1 → 5.0.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 CHANGED
@@ -50,7 +50,10 @@
50
50
  }
51
51
  ],
52
52
  "selector-disallowed-list": [
53
- ["/(?:^|[\\s>+~])\\s*\\.-[a-z]/i"],
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
@@ -11,6 +11,7 @@ This package provides ESLint rules and configurations for **Hubspot Marketing We
11
11
  - [Stylelint Setup](#stylelint-setup)
12
12
  - [Cypress Setup](#cypress-setup)
13
13
  - [Accessibility Testing](#accessibility-testing-optional)
14
+ - [Custom Rules](#custom-rules)
14
15
  - [Where to use it](#where-to-use-it)
15
16
  - [Using the Prettier Scripts](#using-the-prettier-scripts)
16
17
  - [Contributing](#contributing)
@@ -158,6 +159,16 @@ This package includes a shared accessibility testing setup using [cypress-axe](h
158
159
 
159
160
  `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
161
 
162
+ ## Custom Rules
163
+
164
+ 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.
165
+
166
+ | Rule | Severity | Summary |
167
+ |------|----------|---------|
168
+ | `hs-web-team/no-reduce-accumulator-copy` | error | Disallows O(n²) accumulator copies (spread, concat) inside `reduce`/`reduceRight` |
169
+
170
+ For examples and remediation guidance, see [examples/custom-rules.md](./examples/custom-rules.md).
171
+
161
172
  ## Where to use it
162
173
 
163
174
  This package provides multiple configurations:
@@ -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.
package/browser.js CHANGED
@@ -4,6 +4,8 @@ import tseslint from 'typescript-eslint';
4
4
  import reactPlugin from 'eslint-plugin-react';
5
5
  import reactHooksPlugin from 'eslint-plugin-react-hooks';
6
6
  import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
7
+ import { claudeCodePlugin } from './plugins/claude-code/index.js';
8
+ import { hsWebTeamPlugin } from './plugins/hs-web-team/index.js';
7
9
 
8
10
  // Base rules adapted from the browser config
9
11
  const baseRules = {
@@ -38,6 +40,7 @@ const baseRules = {
38
40
  'object-curly-newline': 0,
39
41
  'function-paren-newline': 0,
40
42
  'nonblock-statement-body-position': 0,
43
+ 'max-params': ['warn', { max: 3 }],
41
44
  };
42
45
 
43
46
  // React-specific rules
@@ -63,7 +66,6 @@ const commonIgnores = [
63
66
  '**/build/**',
64
67
  '**/.next/**',
65
68
  '**/coverage/**',
66
- 'eslint.config.js',
67
69
  ];
68
70
 
69
71
  export default [
@@ -71,8 +73,9 @@ export default [
71
73
  {
72
74
  ignores: commonIgnores,
73
75
  },
74
- // Base config for all JavaScript files
75
- js.configs.recommended,
76
+ // Base recommended rules — scoped to JS/TS so they never run against
77
+ // non-JS languages (e.g. the markdown used for CLAUDE.md below).
78
+ { ...js.configs.recommended, files: ['**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'] },
76
79
  {
77
80
  files: ['**/*.{js,mjs,cjs,jsx}'],
78
81
  languageOptions: {
@@ -117,6 +120,7 @@ export default [
117
120
  ...reactRules,
118
121
  },
119
122
  },
123
+ hsWebTeamPlugin.configs.recommended,
120
124
  // TypeScript config
121
125
  ...tseslint.configs.recommended.map(config => ({
122
126
  ...config,
@@ -165,4 +169,6 @@ export default [
165
169
  ...reactRules,
166
170
  },
167
171
  },
172
+ // CLAUDE.md and SKILL.md context-file checks (see plugins/claude-code)
173
+ ...claudeCodePlugin.configs.recommended,
168
174
  ];
@@ -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
- throw new Error('Error: Could not find the hubspot.config.yml file within the projects directories.');
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.error(error);
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.
@@ -0,0 +1,73 @@
1
+ # CLAUDE.md & SKILL.md linting
2
+
3
+ The Node (`.`) and browser (`./browser`) configs include a rule that lints each
4
+ repo's `CLAUDE.md` project-context files and the `SKILL.md` files under
5
+ `.claude/skills/`. It is **on by default** for any project that extends either
6
+ config — no extra setup.
7
+
8
+ `CLAUDE.md` is what gives Claude Code project context (conventions, commands,
9
+ gotchas), and each `SKILL.md` holds a skill's instructions. Oversized context
10
+ files are harder for Claude Code to use effectively and tend to accumulate stale
11
+ guidance, so this rule keeps them small enough to stay useful. Skills carry more
12
+ detail than a `CLAUDE.md`, so they get a higher guideline.
13
+
14
+ ## What it checks
15
+
16
+ | Rule | Target | Severity | Purpose |
17
+ | --- | --- | --- | --- |
18
+ | `claude-code/max-lines` | `**/CLAUDE.md` | `warn` | Flags a `CLAUDE.md` longer than the guideline (default **100** lines). |
19
+ | `claude-code/max-lines` | `**/.claude/skills/**/SKILL.md` | `warn` | Flags a `SKILL.md` longer than the guideline (default **500** lines). |
20
+
21
+ It is a **warning** — it never fails a lint run on its own. Bump it to `error`
22
+ in your own config if you want CI to enforce it.
23
+
24
+ ## Important: your lint command must include these files
25
+
26
+ ESLint only lints files you point it at. `eslint .` picks up `CLAUDE.md` and
27
+ `SKILL.md` automatically; a narrowed command such as `eslint src/**/*.ts` will
28
+ **not**. If you lint a subset of files, add `**/CLAUDE.md` and
29
+ `**/.claude/skills/**/SKILL.md` to the set.
30
+
31
+ ## Overriding
32
+
33
+ The rule is tunable per project in your `eslint.config.js`. Because it targets
34
+ `**/CLAUDE.md`, scope overrides to that file so they don't touch other files:
35
+
36
+ ```js
37
+ import wtConfig from '@hs-web-team/eslint-config-node';
38
+
39
+ export default [
40
+ ...wtConfig,
41
+ {
42
+ files: ['**/CLAUDE.md'],
43
+ rules: {
44
+ // Raise the line limit for a large repo.
45
+ 'claude-code/max-lines': ['warn', { max: 150 }],
46
+ },
47
+ },
48
+ ];
49
+ ```
50
+
51
+ Turn it off entirely:
52
+
53
+ ```js
54
+ {
55
+ files: ['**/CLAUDE.md'],
56
+ rules: { 'claude-code/max-lines': 'off' },
57
+ }
58
+ ```
59
+
60
+ ## Rule options
61
+
62
+ ### `claude-code/max-lines`
63
+
64
+ ```js
65
+ ['warn', { max: 100, advice: 'Trim it or split sections into files Claude Code can import on demand.' }]
66
+ ```
67
+
68
+ - `max` (integer, ≥ 1) — the maximum number of content lines. A trailing
69
+ newline does not count as an extra line. Defaults to **100** (the CLAUDE.md
70
+ block); the SKILL.md block sets it to **500**.
71
+ - `advice` (string) — the guidance appended to the warning message, so you can
72
+ tailor it to the file type. The message also names the offending file (e.g.
73
+ `SKILL.md has 620 lines, over the 500-line guideline. …`).
@@ -0,0 +1,59 @@
1
+ # Custom Rules (`hs-web-team` plugin)
2
+
3
+ This package bundles a small set of custom ESLint rules under the `hs-web-team` plugin. They are active automatically when you spread `wtConfig` or `wtBrowserConfig` — no extra configuration required.
4
+
5
+ ---
6
+
7
+ ## `hs-web-team/no-reduce-accumulator-copy` ❌ error
8
+
9
+ **Disallows O(n²) accumulator copies inside `reduce`/`reduceRight` callbacks.**
10
+
11
+ Each iteration of `reduce` creates an entirely new object or array, making the total work proportional to n². This is rarely intentional and degrades quickly as the input grows.
12
+
13
+ ### What triggers it
14
+
15
+ ```js
16
+ // ❌ Object spread — copies every key on every iteration
17
+ const byId = items.reduce((acc, item) => ({ ...acc, [item.id]: item }), {});
18
+
19
+ // ❌ Array spread — copies every element on every iteration
20
+ const doubled = items.reduce((acc, n) => [...acc, n * 2], []);
21
+
22
+ // ❌ acc.concat() — same problem, different syntax
23
+ const flat = items.reduce((acc, arr) => acc.concat(arr), []);
24
+ ```
25
+
26
+ ### What to do instead
27
+
28
+ ```js
29
+ // ✅ Object.groupBy / Object.fromEntries for grouping/indexing
30
+ const byId = Object.fromEntries(items.map(item => [item.id, item]));
31
+
32
+ // ✅ for...of with in-place mutation for accumulation
33
+ const byId = {};
34
+ for (const item of items) {
35
+ byId[item.id] = item;
36
+ }
37
+
38
+ // ✅ flatMap for flat-map patterns
39
+ const doubled = items.flatMap(n => [n * 2]);
40
+
41
+ // ✅ flat() for flattening
42
+ const flat = items.flat();
43
+ ```
44
+
45
+ ### When `reduce` is fine
46
+
47
+ The rule only fires when the **accumulator itself** is copied. Reads from the accumulator, or spreading non-accumulator values into it, are allowed:
48
+
49
+ ```js
50
+ // ✅ Spreading a non-accumulator value into the result
51
+ const merged = items.reduce((acc, item) => ({ ...acc, ...item.overrides }), base);
52
+ // ^^^^^^^^^^^^^^ not the accumulator — fine
53
+
54
+ // ✅ Accumulator mutation with no copy
55
+ const counts = items.reduce((acc, item) => {
56
+ acc[item.type] = (acc[item.type] ?? 0) + 1;
57
+ return acc;
58
+ }, {});
59
+ ```
package/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import js from '@eslint/js';
2
2
  import globals from 'globals';
3
3
  import tseslint from 'typescript-eslint';
4
+ import { claudeCodePlugin } from './plugins/claude-code/index.js';
5
+ import { hsWebTeamPlugin } from './plugins/hs-web-team/index.js';
4
6
 
5
7
  // Base rules for all JavaScript files
6
8
  const baseRules = {
@@ -43,6 +45,7 @@ const baseRules = {
43
45
  'newline-per-chained-call': 0,
44
46
  indent: 0,
45
47
  'function-paren-newline': 0,
48
+ 'max-params': ['warn', { max: 3 }],
46
49
  };
47
50
 
48
51
  // Common ignore patterns
@@ -51,7 +54,6 @@ const commonIgnores = [
51
54
  '**/.serverless/**',
52
55
  '**/.webpack/**',
53
56
  '**/dist/**',
54
- 'eslint.config.js',
55
57
  ];
56
58
 
57
59
  export default [
@@ -59,8 +61,9 @@ export default [
59
61
  {
60
62
  ignores: commonIgnores,
61
63
  },
62
- // Base config for all JavaScript files
63
- js.configs.recommended,
64
+ // Base recommended rules — scoped to JS/TS so they never run against
65
+ // non-JS languages (e.g. the markdown used for CLAUDE.md below).
66
+ { ...js.configs.recommended, files: ['**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'] },
64
67
  {
65
68
  files: ['**/*.{js,mjs,cjs}'],
66
69
  languageOptions: {
@@ -68,6 +71,7 @@ export default [
68
71
  },
69
72
  rules: baseRules,
70
73
  },
74
+ hsWebTeamPlugin.configs.recommended,
71
75
  // TypeScript config - restrict to TypeScript files only
72
76
  ...tseslint.configs.recommended.map(config => ({
73
77
  ...config,
@@ -80,4 +84,6 @@ export default [
80
84
  },
81
85
  rules: baseRules,
82
86
  },
87
+ // CLAUDE.md and SKILL.md context-file checks (see plugins/claude-code)
88
+ ...claudeCodePlugin.configs.recommended,
83
89
  ];
package/package.json CHANGED
@@ -1,7 +1,20 @@
1
1
  {
2
2
  "name": "@hs-web-team/eslint-config-node",
3
- "version": "4.2.1",
3
+ "version": "5.0.0",
4
4
  "description": "HubSpot Marketing WebTeam shared configurations for ESLint, Prettier, Stylelint, and Cypress",
5
+ "files": [
6
+ ".prettierrc.json",
7
+ ".stylelintrc.json",
8
+ "bin/",
9
+ "browser.js",
10
+ "cypress.config.cjs",
11
+ "cypress.config.d.ts",
12
+ "cypress/",
13
+ "docs/",
14
+ "examples/",
15
+ "index.js",
16
+ "plugins/"
17
+ ],
5
18
  "main": "index.js",
6
19
  "type": "module",
7
20
  "exports": {
@@ -11,12 +24,14 @@
11
24
  "./.stylelintrc.json": "./.stylelintrc.json",
12
25
  "./cypress.config": {
13
26
  "types": "./cypress.config.d.ts",
14
- "require": "./cypress.config.cjs"
27
+ "require": "./cypress.config.cjs",
28
+ "default": "./cypress.config.cjs"
15
29
  },
16
30
  "./cypress": {
17
31
  "types": "./cypress/index.d.ts",
18
32
  "default": "./cypress/index.js"
19
- }
33
+ },
34
+ "./plugin": "./plugins/hs-web-team/index.js"
20
35
  },
21
36
  "typesVersions": {
22
37
  "*": {
@@ -30,8 +45,8 @@
30
45
  },
31
46
  "scripts": {
32
47
  "postinstall": "node bin/check-peer-deps.cjs",
33
- "lint": "npx eslint -c ./index.js *.js --fix",
34
- "test": "echo \"Error: no test specified\" && exit 1",
48
+ "lint": "npx eslint . --fix",
49
+ "test": "node --test",
35
50
  "prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .githooks || true"
36
51
  },
37
52
  "engines": {
@@ -59,29 +74,30 @@
59
74
  },
60
75
  "homepage": "https://github.com/HubSpotWebTeam/wt-eslint-node#readme",
61
76
  "dependencies": {
62
- "@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
63
- "@eslint/eslintrc": "^3.3.5",
77
+ "@bahmutov/cypress-esbuild-preprocessor": "2.2.8",
78
+ "@eslint/eslintrc": "3.3.6",
64
79
  "@eslint/js": "^9.39.1",
65
- "@typescript-eslint/eslint-plugin": "^8.46.3",
66
- "@typescript-eslint/parser": "^8.46.3",
67
- "axe-core": "^4.11.4",
68
- "cypress-axe": "^1.7.0",
69
- "esbuild": "^0.28.0",
70
- "eslint": "^9.39.4",
71
- "eslint-formatter-checkstyle": "^9.0.1",
72
- "eslint-plugin-jsx-a11y": "^6.10.2",
73
- "eslint-plugin-react": "^7.37.5",
74
- "eslint-plugin-react-hooks": "^7.1.1",
75
- "globals": "^17.6.0",
76
- "jiti": "^2.7.0",
77
- "js-yaml": "^4.1.1",
78
- "prettier": "^3.8.3",
80
+ "@eslint/markdown": "8.0.3",
81
+ "@typescript-eslint/eslint-plugin": "^8.65.0",
82
+ "@typescript-eslint/parser": "^8.65.0",
83
+ "axe-core": "4.13.0",
84
+ "cypress-axe": "1.7.0",
85
+ "esbuild": "0.28.2",
86
+ "eslint": "9.39.5",
87
+ "eslint-formatter-checkstyle": "9.0.1",
88
+ "eslint-plugin-jsx-a11y": "6.10.2",
89
+ "eslint-plugin-react": "7.37.5",
90
+ "eslint-plugin-react-hooks": "7.1.1",
91
+ "globals": "17.11.0",
92
+ "jiti": "2.7.0",
93
+ "js-yaml": "5.4.0",
94
+ "prettier": "3.9.6",
79
95
  "prettier-plugin-gherkin": "4.0.0",
80
- "typescript-eslint": "^8.60.0"
96
+ "typescript-eslint": "8.68.0"
81
97
  },
82
98
  "peerDependencies": {
83
- "@badeball/cypress-cucumber-preprocessor": "^24.0.1",
84
- "cypress": "^15.16.0",
99
+ "@badeball/cypress-cucumber-preprocessor": "24.0.1",
100
+ "cypress": "15.21.0",
85
101
  "stylelint": "^17.1.1",
86
102
  "stylelint-config-standard": "^40.0.0",
87
103
  "stylelint-config-standard-scss": "^17.0.0",