@hs-web-team/eslint-config-node 4.2.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.stylelintrc.json +6 -3
- package/README.md +33 -0
- package/base-rules.js +61 -0
- package/bin/check-peer-deps.cjs +1 -1
- package/bin/gherkin-tag-coverage.js +62 -0
- package/browser.js +13 -62
- package/cypress.config.cjs +10 -11
- package/docs/adr/0001-lint-claude-md-with-eslint.md +123 -0
- package/docs/adr/0002-extend-max-lines-to-skill-md.md +105 -0
- package/examples/browser-usage.md +20 -0
- package/examples/claude-code-usage.md +73 -0
- package/examples/custom-rules.md +98 -0
- package/examples/gherkin-usage.md +133 -0
- package/gherkin/.gplintrc +32 -0
- package/gherkin/coverage-config.js +10 -0
- package/gherkin/load-config.js +23 -0
- package/gherkin/suite-coverage.js +126 -0
- package/index.js +12 -55
- package/package.json +54 -24
- package/plugins/claude-code/index.js +50 -0
- package/plugins/claude-code/rules/max-lines.js +52 -0
- package/plugins/hs-web-team/index.js +24 -0
- package/plugins/hs-web-team/rules/no-abbreviations.js +135 -0
- package/plugins/hs-web-team/rules/no-non-literal-constant-case.js +137 -0
- package/plugins/hs-web-team/rules/no-reduce-accumulator-copy.js +90 -0
- package/.githooks/commit-msg +0 -28
- package/.github/CODEOWNERS +0 -1
- package/.github/workflows/main.yml +0 -24
- package/.github/workflows/pr.yml +0 -24
- package/.github/workflows/release-please.yml +0 -44
- package/.nvmrc +0 -1
- package/.release-please-manifest.json +0 -3
- package/CHANGELOG.md +0 -23
- package/CLAUDE.md +0 -145
- package/release-please-config.json +0 -8
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
|
63
|
-
|
|
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:
|
|
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:
|
|
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
|
];
|