@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.
@@ -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,98 @@
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
+ ```
60
+
61
+ ---
62
+
63
+ ## `hs-web-team/no-non-literal-constant-case` ⚠️ warn
64
+
65
+ **Disallows `CONSTANT_CASE` names whose value isn't actually static.**
66
+
67
+ A `CONSTANT_CASE` name signals to readers that the value is fixed and safe to assume unchanged for the lifetime of the program. When the value is actually computed at runtime — a function call, a conditional, a reference to a mutable binding — the name is misleading and can cost real debugging time (this rule was added after a `CONSTANT_CASE`-named variable turned out to be a debug-only runtime computation, defaulting to `0` for normal visitors).
68
+
69
+ ### What triggers it
70
+
71
+ ```js
72
+ // ❌ Conditional — depends on runtime state
73
+ const REDIRECT_DELAY_MS = isDebugMode() ? 1000 : 0;
74
+
75
+ // ❌ Function call result
76
+ const TOKEN = generateToken();
77
+
78
+ // ❌ Declared with let/var — can be reassigned regardless of its current value
79
+ let API_HOST = 'www.api-host.com';
80
+ ```
81
+
82
+ ### What to do instead
83
+
84
+ ```js
85
+ // ✅ Rename to camelCase if the value genuinely isn't a constant
86
+ const redirectDelayMs = isDebugMode() ? 1000 : 0;
87
+
88
+ // ✅ Keep CONSTANT_CASE only when the value is a literal (or built purely from literals)
89
+ const API_HOST = 'www.api-host.com';
90
+ const RETRY_LIMITS = Object.freeze({ max: 3, backoffMs: 500 });
91
+ const SORT_METHODS = new Set(['sort', 'toSorted']);
92
+ ```
93
+
94
+ ### What's allowed
95
+
96
+ Literals, template literals with no interpolation, unary-wrapped literals (`-1`), arrays/objects composed entirely of the above (optionally wrapped in `Object.freeze(...)`), `new Set`/`new Map`/`new RegExp` (and their `Weak` variants) built from literal arguments, `process.env.X` references, and references to other `CONSTANT_CASE`-named identifiers are all treated as static and won't be flagged.
97
+
98
+ **Not covered:** this rule only checks whether the *value* is static — it does not enforce that `CONSTANT_CASE` be reserved for exported bindings (a separate naming convention with much broader rollout impact, intentionally left out of this rule for now).
@@ -0,0 +1,133 @@
1
+ # Gherkin Tag Linting Usage
2
+
3
+ This package provides shared tag linting for Cypress/Cucumber feature files.
4
+
5
+ Cucumber selects which tests to run by tag, so a scenario with no tags is silently skipped. A
6
+ suite can be fully green while some of its tests never execute. These two checks turn that into
7
+ a build failure.
8
+
9
+ ## Applies to
10
+
11
+ Only repositories whose Cypress suite is written in Gherkin, using
12
+ `@badeball/cypress-cucumber-preprocessor` and `.feature` files.
13
+
14
+ Both checks read `.feature` files and nothing else, so:
15
+
16
+ - A Cypress suite written in Mocha (`.cy.js` / `.cy.ts` with `describe`/`it`) has nothing to
17
+ check. There are no tags to require, because Mocha does not select tests by tag.
18
+ - Existing consumers of `@hs-web-team/eslint-config-node` are unaffected by upgrading. Neither
19
+ check is wired into `wtConfig`, `wtBrowserConfig`, or any Cypress config. Nothing runs until a
20
+ repository adds the `lint:gherkin` script below.
21
+
22
+ ## What is checked
23
+
24
+ **Per scenario**, every scenario must carry:
25
+
26
+ - at least one environment tag: `@qa` or `@prod`. Both together is valid.
27
+ - at least one locale tag: `@en`, `@es`, `@fr`, `@de`, `@jp`, `@br`. Any number together is valid.
28
+
29
+ Any other tag is allowed and ignored, including TestRail ids like `@a72w9o`. There is no tag
30
+ allowlist.
31
+
32
+ A tag counts wherever it is declared: the `Feature` line, a `Rule:`, the `Scenario:`, or an
33
+ `Examples:` block. Inheritance works the same way as Cucumber's own `--tags` filtering.
34
+
35
+ **Per suite**, the suite must contain at least one scenario tagged `@smoke @qa @en` and at least
36
+ one tagged `@smoke @prod @en`. The same scenario may satisfy both, for example one tagged
37
+ `@qa @prod @en @smoke`.
38
+
39
+ This second check cannot be a lint rule, because a lint rule only ever sees one file at a time
40
+ and never the whole suite.
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ npm i -D @hs-web-team/eslint-config-node@latest
46
+ ```
47
+
48
+ `gplint` and the Gherkin parser are bundled, so there is nothing else to install.
49
+
50
+ ## Usage
51
+
52
+ Add one script to your `package.json`:
53
+
54
+ ```json
55
+ {
56
+ "scripts": {
57
+ "lint:gherkin": "gplint cypress/e2e --config=node_modules/@hs-web-team/eslint-config-node/gherkin/.gplintrc && gherkin-tag-coverage"
58
+ }
59
+ }
60
+ ```
61
+
62
+ Then run it:
63
+
64
+ ```bash
65
+ npm run lint:gherkin
66
+ ```
67
+
68
+ Both checks print nothing when they pass. A failure looks like this:
69
+
70
+ ```
71
+ /path/to/cypress/e2e/login.feature
72
+ 3:3 error The tag(s) [@qa,@prod] should be present for Scenario. required-tags
73
+ 3:3 error The tag(s) [@en,@es,@fr,@de,@jp,@br] should be present for Scenario. required-tags
74
+
75
+ ✖ 2 problems (2 errors, 0 warnings)
76
+ ```
77
+
78
+ Use `&&` rather than `;` to join the two commands. With `;` the exit code comes from the last
79
+ command only, so a tag violation would pass CI.
80
+
81
+ ## QA-only repositories
82
+
83
+ If a repository has no production environment, the `@smoke @prod @en` requirement can never be
84
+ satisfied. Opt out by adding a `gherkin-coverage.json` next to `package.json`:
85
+
86
+ ```json
87
+ { "requiredCombinations": [["@smoke", "@qa", "@en"]] }
88
+ ```
89
+
90
+ Do not satisfy the requirement by tagging a scenario `@prod` instead. Tags are what CI selects
91
+ on, so that scenario would become eligible for the production run.
92
+
93
+ A bare array works too:
94
+
95
+ ```json
96
+ [["@smoke", "@qa", "@en"]]
97
+ ```
98
+
99
+ ## Passing a different path
100
+
101
+ Both commands default to `cypress/e2e`. To point elsewhere:
102
+
103
+ ```bash
104
+ gplint tests/features --config=node_modules/@hs-web-team/eslint-config-node/gherkin/.gplintrc
105
+ gherkin-tag-coverage tests/features
106
+ ```
107
+
108
+ Pass a **directory**, not a glob. `gplint` rewrites patterns containing `/**`, so
109
+ `cypress/e2e/**/*.feature` matches nothing. Directories recurse to any depth.
110
+
111
+ ## Notes
112
+
113
+ - `gplint` exits 0 when it matches no feature files, so a wrong path looks the same as a pass.
114
+ Check that the reported file count is non-zero when first setting this up, using
115
+ `--format=json` to list the files it found.
116
+ - Tags are case sensitive. `@EN` does not satisfy `@en`, which is correct, because Cucumber
117
+ would not select it either.
118
+ - A scenario written after a `Rule:` belongs to that Rule even if it is dedented back to feature
119
+ level, and inherits the Rule's tags. This is Gherkin behaviour, not a linter quirk.
120
+ - An `Examples` table row cannot be tagged individually. Tags attach to the `Examples:` block and
121
+ every row inherits them.
122
+ - Whitespace and formatting rules are deliberately not included, because Prettier owns those via
123
+ the bundled `prettier-plugin-gherkin`.
124
+ - `gplint` prints an `ExperimentalWarning: Importing JSON modules` line to stderr on every run.
125
+ It is harmless.
126
+
127
+ ## Exit codes
128
+
129
+ | Code | Meaning |
130
+ | ---- | ----------------------------------------------------------------------------------------- |
131
+ | 0 | Passed |
132
+ | 1 | A real violation, or a missing suite-wide combination |
133
+ | 2 | Bad input, for example an unparseable feature file or a malformed `gherkin-coverage.json` |
@@ -0,0 +1,32 @@
1
+ {
2
+ // Every scenario needs one tag from each list. Inherited tags count (Feature, Rule,
3
+ // Examples). Other tags are ignored. ignoreUntagged must stay false or fully untagged
4
+ // scenarios are skipped.
5
+ "required-tags": ["error", {
6
+ "ignoreUntagged": false,
7
+ "global": [
8
+ ["@qa", "@prod"],
9
+ ["@en", "@es", "@fr", "@de", "@jp", "@br"]
10
+ ]
11
+ }],
12
+
13
+ "no-duplicate-tags": "error",
14
+ // allowSeparated:false so "@qa @en #@smoke" fails. The default true allows it.
15
+ "no-partially-commented-tag-lines": ["error", { "allowSeparated": false }],
16
+
17
+ "no-empty-file": "error",
18
+ "no-files-without-scenarios": "error",
19
+ "no-unnamed-features": "error",
20
+ "no-unnamed-scenarios": "error",
21
+ "no-scenario-outlines-without-examples": "error",
22
+ "no-empty-background": "error",
23
+ "no-background-only-scenario": "error",
24
+ "no-dupe-feature-names": "error",
25
+ // in-feature, not the default "anywhere", which flags the same name in unrelated features.
26
+ "no-dupe-scenario-names": ["error", "in-feature"],
27
+ "use-and": "error",
28
+ "name-length": ["error", { "Feature": 60, "Scenario": 120, "Step": 475 }]
29
+
30
+ // Whitespace rules (indentation, no-trailing-spaces, new-line-at-eof,
31
+ // no-multiple-empty-lines) left off: prettier owns those.
32
+ }
@@ -0,0 +1,10 @@
1
+ // Tag combinations the suite as a whole must contain at least one scenario for.
2
+ // One scenario may satisfy several entries, e.g. a scenario tagged @qa @prod @en @smoke
3
+ // covers both of these.
4
+ //
5
+ // Override per repo with a `gherkin-coverage.json` file next to package.json, either as a
6
+ // bare array of combinations or as `{ "requiredCombinations": [...] }`.
7
+ export default [
8
+ ['@smoke', '@qa', '@en'],
9
+ ['@smoke', '@prod', '@en'],
10
+ ];
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import stripJsonComments from 'strip-json-comments';
4
+
5
+ import defaultCombinations from './coverage-config.js';
6
+
7
+ const OVERRIDE_FILE = 'gherkin-coverage.json';
8
+
9
+ export async function loadCoverageConfig(cwd = process.cwd()) {
10
+ const overridePath = path.join(cwd, OVERRIDE_FILE);
11
+ if (!fs.existsSync(overridePath)) {
12
+ return { combinations: defaultCombinations, overridePath: null };
13
+ }
14
+
15
+ const override = JSON.parse(stripJsonComments(fs.readFileSync(overridePath, 'utf8')));
16
+ const combinations = Array.isArray(override) ? override : override?.requiredCombinations;
17
+
18
+ if (!Array.isArray(combinations) || combinations.some(combo => !Array.isArray(combo))) {
19
+ throw new Error(`${overridePath} must be an array of tag arrays, or { "requiredCombinations": [[...]] }.`);
20
+ }
21
+
22
+ return { combinations, overridePath };
23
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Checks that the suite contains at least one scenario for each required tag combination.
3
+ *
4
+ * A gplint rule cannot do this: it is handed one file at a time and never sees the suite.
5
+ *
6
+ * Tags count no matter where they come from: the Feature line, a Rule, the Scenario, or an
7
+ * Examples block. This works because each file is compiled into "pickles" the same way
8
+ * Cucumber does, and a pickle's tag list is already the merged set Cucumber matches against
9
+ * when you run --tags.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import * as gherkin from '@cucumber/gherkin';
15
+ import * as messages from '@cucumber/messages';
16
+
17
+ export const RULE = 'suite-tag-coverage';
18
+
19
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
20
+
21
+ export function findFeatureFiles(target) {
22
+ if (fs.statSync(target).isFile()) {
23
+ return target.endsWith('.feature') ? [target] : [];
24
+ }
25
+
26
+ const found = [];
27
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
28
+ const full = path.join(target, entry.name);
29
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
30
+ found.push(...findFeatureFiles(full));
31
+ } else if (entry.isFile() && entry.name.endsWith('.feature')) {
32
+ found.push(full);
33
+ }
34
+ }
35
+ return found;
36
+ }
37
+
38
+ function parse(filePath) {
39
+ const parser = new gherkin.Parser(
40
+ new gherkin.AstBuilder(messages.IdGenerator.uuid()),
41
+ new gherkin.GherkinClassicTokenMatcher(),
42
+ );
43
+ const doc = parser.parse(fs.readFileSync(filePath, 'utf8'));
44
+ return { pickles: gherkin.compile(doc, filePath, messages.IdGenerator.uuid()), doc };
45
+ }
46
+
47
+ // A pickle knows which AST nodes it came from but not its line number, so build id -> line.
48
+ function lineIndex(doc) {
49
+ const lines = new Map();
50
+ const visit = children => {
51
+ for (const child of children ?? []) {
52
+ if (child.rule) visit(child.rule.children);
53
+ if (!child.scenario) continue;
54
+ lines.set(child.scenario.id, child.scenario.location.line);
55
+ for (const examples of child.scenario.examples ?? []) {
56
+ for (const row of examples.tableBody ?? []) {
57
+ lines.set(row.id, row.location.line);
58
+ }
59
+ }
60
+ }
61
+ };
62
+ visit(doc.feature?.children);
63
+ return lines;
64
+ }
65
+
66
+ export function collectScenarios(files) {
67
+ const scenarios = [];
68
+ for (const file of files) {
69
+ const parsed = parse(file);
70
+ const lines = lineIndex(parsed.doc);
71
+ for (const pickle of parsed.pickles) {
72
+ scenarios.push({
73
+ file,
74
+ name: pickle.name,
75
+ line: lines.get(pickle.astNodeIds.at(-1)) ?? '?',
76
+ tags: new Set(pickle.tags.map(tag => tag.name)),
77
+ });
78
+ }
79
+ }
80
+ return scenarios;
81
+ }
82
+
83
+ /** Returns one problem per uncovered combination, anchored at the closest scenario. */
84
+ export function findUncovered(scenarios, combinations) {
85
+ return combinations
86
+ .filter(combo => !scenarios.some(scenario => combo.every(tag => scenario.tags.has(tag))))
87
+ .map(combo => {
88
+ // Rank by how many tags are missing so the suggestion is the smallest possible fix.
89
+ const closest = scenarios
90
+ .map(scenario => ({ scenario, missing: combo.filter(tag => !scenario.tags.has(tag)) }))
91
+ .sort((a, b) => a.missing.length - b.missing.length)[0];
92
+
93
+ return {
94
+ file: closest.scenario.file,
95
+ line: closest.scenario.line,
96
+ column: 3,
97
+ message: `No scenario in the suite is tagged [${combo.join(' ')}]. Add ${closest.missing.join(' ')} here, or tag another scenario.`,
98
+ };
99
+ });
100
+ }
101
+
102
+ /** gplint's stylish format, so the lint and coverage checks read as one tool. */
103
+ export function formatProblems(problems) {
104
+ const byFile = new Map();
105
+ for (const problem of problems) {
106
+ if (!byFile.has(problem.file)) byFile.set(problem.file, []);
107
+ byFile.get(problem.file).push(problem);
108
+ }
109
+
110
+ const width = Math.max(...problems.map(problem => `${problem.line}:${problem.column}`.length));
111
+ const lines = [];
112
+
113
+ for (const [file, fileProblems] of [...byFile.entries()].sort()) {
114
+ lines.push('', path.resolve(file));
115
+ for (const problem of fileProblems.sort((a, b) => a.line - b.line)) {
116
+ const at = `${problem.line}:${problem.column}`.padStart(width);
117
+ lines.push(` ${at} error ${problem.message} ${RULE}`);
118
+ }
119
+ }
120
+
121
+ const count = problems.length;
122
+ const plural = count === 1 ? '' : 's';
123
+ lines.push('', `✖ ${count} problem${plural} (${count} error${plural}, 0 warnings)`, '');
124
+
125
+ return lines.join('\n');
126
+ }
package/index.js CHANGED
@@ -1,73 +1,28 @@
1
1
  import js from '@eslint/js';
2
- import globals from 'globals';
3
2
  import tseslint from 'typescript-eslint';
4
-
5
- // Base rules for all JavaScript files
6
- const baseRules = {
7
- 'no-console': [
8
- 'error',
9
- {
10
- allow: ['info', 'warn', 'error'],
11
- },
12
- ],
13
- camelcase: 'off',
14
- 'comma-dangle': ['warn', 'always-multiline'],
15
- 'arrow-parens': 0,
16
- 'no-plusplus': 0,
17
- 'no-underscore-dangle': [
18
- 'error',
19
- {
20
- allow: [
21
- '__dirname',
22
- '__filename',
23
- ],
24
- },
25
- ],
26
- 'no-confusing-arrow': 0,
27
- 'import/no-unresolved': 0,
28
- 'import/prefer-default-export': 0,
29
- 'no-trailing-spaces': ['error', { skipBlankLines: true }],
30
- 'no-unused-expressions': ['warn', { allowTernary: true }],
31
- 'max-len': [
32
- 2,
33
- {
34
- code: 120,
35
- ignoreStrings: true,
36
- ignoreTemplateLiterals: true,
37
- },
38
- ],
39
- 'operator-linebreak': 0,
40
- 'implicit-arrow-linebreaks': 0,
41
- 'implicit-arrow-linebreak': 0,
42
- 'object-curly-newline': 0,
43
- 'newline-per-chained-call': 0,
44
- indent: 0,
45
- 'function-paren-newline': 0,
46
- };
3
+ import { claudeCodePlugin } from './plugins/claude-code/index.js';
4
+ import { hsWebTeamPlugin } from './plugins/hs-web-team/index.js';
5
+ import { nodeBaseRules as baseRules, nodeGlobals } from './base-rules.js';
47
6
 
48
7
  // Common ignore patterns
49
- const commonIgnores = [
50
- '**/node_modules/**',
51
- '**/.serverless/**',
52
- '**/.webpack/**',
53
- '**/dist/**',
54
- 'eslint.config.js',
55
- ];
8
+ const commonIgnores = ['**/node_modules/**', '**/.serverless/**', '**/.webpack/**', '**/dist/**'];
56
9
 
57
10
  export default [
58
11
  // Global ignores
59
12
  {
60
13
  ignores: commonIgnores,
61
14
  },
62
- // Base config for all JavaScript files
63
- js.configs.recommended,
15
+ // Base recommended rules — scoped to JS/TS so they never run against
16
+ // non-JS languages (e.g. the markdown used for CLAUDE.md below).
17
+ { ...js.configs.recommended, files: ['**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'] },
64
18
  {
65
19
  files: ['**/*.{js,mjs,cjs}'],
66
20
  languageOptions: {
67
- globals: {...globals.node, ...globals.es2022, ...globals.jest},
21
+ globals: nodeGlobals,
68
22
  },
69
23
  rules: baseRules,
70
24
  },
25
+ hsWebTeamPlugin.configs.recommended,
71
26
  // TypeScript config - restrict to TypeScript files only
72
27
  ...tseslint.configs.recommended.map(config => ({
73
28
  ...config,
@@ -76,8 +31,10 @@ export default [
76
31
  {
77
32
  files: ['**/*.{ts,mts,cts,tsx}'],
78
33
  languageOptions: {
79
- globals: {...globals.node, ...globals.es2022, ...globals.jest},
34
+ globals: nodeGlobals,
80
35
  },
81
36
  rules: baseRules,
82
37
  },
38
+ // CLAUDE.md and SKILL.md context-file checks (see plugins/claude-code)
39
+ ...claudeCodePlugin.configs.recommended,
83
40
  ];