@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 CHANGED
@@ -21,9 +21,9 @@
21
21
  ],
22
22
  "scss/dollar-variable-pattern": null,
23
23
  "selector-class-pattern": [
24
- "^-?[a-z0-9]([a-z0-9]|-(?!-))*$",
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 should only contain lowercase alphanumeric characters and hyphens, with no consecutive hyphens",
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
- ["/(?:^|[\\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
@@ -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
+ };
@@ -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
- // Base rules adapted from the browser config
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 config for all JavaScript files
75
- js.configs.recommended,
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
  ];
@@ -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.
@@ -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: