@hs-web-team/eslint-config-node 4.2.2 → 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 +4 -1
- package/README.md +11 -0
- package/bin/check-peer-deps.cjs +1 -1
- package/browser.js +9 -3
- 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/claude-code-usage.md +73 -0
- package/examples/custom-rules.md +59 -0
- package/index.js +9 -3
- package/package.json +38 -23
- 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 +21 -0
- package/plugins/hs-web-team/rules/no-abbreviations.js +135 -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
|
@@ -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
|
@@ -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:
|
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.
|
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
|
|
75
|
-
|
|
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
|
];
|
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.
|
|
@@ -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
|
|
63
|
-
|
|
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": "
|
|
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": {
|
|
@@ -17,7 +30,8 @@
|
|
|
17
30
|
"./cypress": {
|
|
18
31
|
"types": "./cypress/index.d.ts",
|
|
19
32
|
"default": "./cypress/index.js"
|
|
20
|
-
}
|
|
33
|
+
},
|
|
34
|
+
"./plugin": "./plugins/hs-web-team/index.js"
|
|
21
35
|
},
|
|
22
36
|
"typesVersions": {
|
|
23
37
|
"*": {
|
|
@@ -31,8 +45,8 @@
|
|
|
31
45
|
},
|
|
32
46
|
"scripts": {
|
|
33
47
|
"postinstall": "node bin/check-peer-deps.cjs",
|
|
34
|
-
"lint": "npx eslint
|
|
35
|
-
"test": "
|
|
48
|
+
"lint": "npx eslint . --fix",
|
|
49
|
+
"test": "node --test",
|
|
36
50
|
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .githooks || true"
|
|
37
51
|
},
|
|
38
52
|
"engines": {
|
|
@@ -60,29 +74,30 @@
|
|
|
60
74
|
},
|
|
61
75
|
"homepage": "https://github.com/HubSpotWebTeam/wt-eslint-node#readme",
|
|
62
76
|
"dependencies": {
|
|
63
|
-
"@bahmutov/cypress-esbuild-preprocessor": "
|
|
64
|
-
"@eslint/eslintrc": "
|
|
77
|
+
"@bahmutov/cypress-esbuild-preprocessor": "2.2.8",
|
|
78
|
+
"@eslint/eslintrc": "3.3.6",
|
|
65
79
|
"@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
|
-
"
|
|
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",
|
|
80
95
|
"prettier-plugin-gherkin": "4.0.0",
|
|
81
|
-
"typescript-eslint": "
|
|
96
|
+
"typescript-eslint": "8.68.0"
|
|
82
97
|
},
|
|
83
98
|
"peerDependencies": {
|
|
84
|
-
"@badeball/cypress-cucumber-preprocessor": "
|
|
85
|
-
"cypress": "
|
|
99
|
+
"@badeball/cypress-cucumber-preprocessor": "24.0.1",
|
|
100
|
+
"cypress": "15.21.0",
|
|
86
101
|
"stylelint": "^17.1.1",
|
|
87
102
|
"stylelint-config-standard": "^40.0.0",
|
|
88
103
|
"stylelint-config-standard-scss": "^17.0.0",
|