@sinemacula/coding-standards 1.8.4 → 1.9.1
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/README.md +75 -1
- package/js/biome.json +15 -1
- package/js/eslint/index.js +86 -0
- package/js/eslint/plugin.js +34 -0
- package/js/eslint/rules/boolean-method-name.js +315 -0
- package/js/eslint/rules/lib.js +90 -0
- package/js/eslint/rules/max-methods-per-class.js +92 -0
- package/js/eslint/rules/no-base-error.js +120 -0
- package/js/eslint/rules/no-interface-prefix.js +55 -0
- package/js/eslint/rules/no-mutable-static.js +213 -0
- package/js/eslint/rules/require-copyright.js +73 -0
- package/js/eslint/rules/require-readonly-public-property.js +154 -0
- package/js/eslint/rules/valid-enum-member-name.js +46 -0
- package/js/eslint/type-checked.js +41 -0
- package/package.json +43 -2
package/README.md
CHANGED
|
@@ -110,6 +110,52 @@ After installing the npm package, extend the shared Biome config from your proje
|
|
|
110
110
|
math against `node_modules/` required). Project-specific `files.includes` and `files.excludes` stay in the consumer
|
|
111
111
|
config.
|
|
112
112
|
|
|
113
|
+
### ESLint (JavaScript / TypeScript)
|
|
114
|
+
|
|
115
|
+
ESLint runs *alongside* Biome, not in place of it. Biome keeps owning formatting and the fast syntactic lint; ESLint
|
|
116
|
+
adds only the two things Biome structurally cannot express: this package's custom structural rules and the opt-in
|
|
117
|
+
type-aware rules (the curated typescript-eslint set plus the type-driven custom rules). Add the linter, the
|
|
118
|
+
typescript-eslint tooling, and this package to your dev dependencies:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm install --save-dev eslint typescript typescript-eslint eslint-plugin-jsdoc @sinemacula/coding-standards
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The package exposes two flat-config entry points:
|
|
125
|
+
|
|
126
|
+
- `@sinemacula/coding-standards/js/eslint` - the base layer of syntax-only custom rules; needs no `tsconfig`, so it
|
|
127
|
+
stays cheap and runs anywhere Biome runs.
|
|
128
|
+
- `@sinemacula/coding-standards/js/eslint/type-checked` - the opt-in type-aware layer. It includes the base layer and
|
|
129
|
+
adds the cross-file / type-driven rules, so it needs a consumer `tsconfig`; use it in place of the base layer where
|
|
130
|
+
one exists.
|
|
131
|
+
|
|
132
|
+
Create an `eslint.config.js` (or `.qlty/configs/eslint.config.js` when wired through Qlty) that spreads the layer you
|
|
133
|
+
want. Without a `tsconfig`, use the base layer:
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import sm from '@sinemacula/coding-standards/js/eslint';
|
|
137
|
+
|
|
138
|
+
export default [...sm];
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Where a `tsconfig` exists, use the type-aware layer instead (it already carries the base rules):
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
import typeChecked from '@sinemacula/coding-standards/js/eslint/type-checked';
|
|
145
|
+
|
|
146
|
+
export default [...typeChecked];
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
When wiring ESLint through Qlty, the shared eslint plugin sandbox installs only `eslint`, `jest`, and `prettier` by
|
|
150
|
+
default, so the flat config's imports of this package and `typescript-eslint` fail to resolve. Widen the install
|
|
151
|
+
filter in your `.qlty/qlty.toml` so the sandbox carries them (this repository's `source.toml` exports the same
|
|
152
|
+
override, but source-exported plugin definitions do not reliably propagate, so mirror it consumer-side):
|
|
153
|
+
|
|
154
|
+
```toml
|
|
155
|
+
[plugins.definitions.eslint]
|
|
156
|
+
package_filters = ["@sinemacula/coding-standards", "typescript-eslint", "@typescript-eslint", "eslint-plugin-jsdoc"]
|
|
157
|
+
```
|
|
158
|
+
|
|
113
159
|
### Knip (JavaScript / TypeScript)
|
|
114
160
|
|
|
115
161
|
```json
|
|
@@ -141,6 +187,7 @@ tag = "<version>"
|
|
|
141
187
|
| `php/phpstan-base.neon` | PHPStan | Base config (org-wide ignored errors + settings) |
|
|
142
188
|
| `js/biome.json` | Biome | JavaScript / TypeScript formatter + linter rules |
|
|
143
189
|
| `js/knip.json` | Knip | Unused-export detection rules |
|
|
190
|
+
| `js/eslint/` | ESLint | Custom structural + type-aware rules; runs with Biome |
|
|
144
191
|
| `markdown/.markdownlint.json` | markdownlint | Markdown linting rules |
|
|
145
192
|
| `yaml/.yamllint.yaml` | yamllint | YAML linting rules |
|
|
146
193
|
| `shell/.shellcheckrc` | ShellCheck | Shell script linting rules |
|
|
@@ -150,7 +197,8 @@ tag = "<version>"
|
|
|
150
197
|
## Rules
|
|
151
198
|
|
|
152
199
|
These are the custom rules this package enforces on top of PSR-12. A deliberate exception can be bypassed with the
|
|
153
|
-
native directive - `// phpcs:ignore <code>` for a sniff, `@phpstan-ignore <identifier>` for a rule
|
|
200
|
+
native directive - `// phpcs:ignore <code>` for a sniff, `@phpstan-ignore <identifier>` for a rule,
|
|
201
|
+
`// eslint-disable-next-line <rule>` for an ESLint rule.
|
|
154
202
|
|
|
155
203
|
### PHPCS sniffs
|
|
156
204
|
|
|
@@ -186,6 +234,32 @@ native directive - `// phpcs:ignore <code>` for a sniff, `@phpstan-ignore <ident
|
|
|
186
234
|
|------------|----------|
|
|
187
235
|
| `sineMacula.mutableStaticProperty` | Static properties written at runtime; `@managed-static` opts out. |
|
|
188
236
|
|
|
237
|
+
### ESLint rules
|
|
238
|
+
|
|
239
|
+
All rules run in the base layer except `boolean-method-name`, which resolves return types and so requires the opt-in
|
|
240
|
+
type-checked layer.
|
|
241
|
+
|
|
242
|
+
| Rule | Enforces |
|
|
243
|
+
|------|----------|
|
|
244
|
+
| `@sinemacula/no-interface-prefix` | Interface and type-alias names must not use the Hungarian `I` prefix. |
|
|
245
|
+
| `@sinemacula/require-readonly-public-property` | Public class properties (declared or promoted) must be `readonly`. |
|
|
246
|
+
| `@sinemacula/valid-enum-member-name` | Enum members must be declared in `SCREAMING_SNAKE_CASE`. |
|
|
247
|
+
| `@sinemacula/boolean-method-name` | Boolean-returning methods need an is/has/can prefix; `@imperative` exempt. |
|
|
248
|
+
| `@sinemacula/no-mutable-static` | No mutable exported bindings or mutable `static` class fields; test code exempt. |
|
|
249
|
+
| `@sinemacula/max-methods-per-class` | A single class may declare at most 20 methods; test code exempt. |
|
|
250
|
+
| `@sinemacula/no-base-error` | Throw a domain-specific `Error` subclass, never the base `Error`; test code exempt. |
|
|
251
|
+
| `@sinemacula/require-copyright` | Every file must carry a documentation comment with `@copyright` and `@author`. |
|
|
252
|
+
|
|
253
|
+
`boolean-method-name` takes `additionalPrefixes`, `additionalPredicates` and `additionalCommandVerbs` (string arrays)
|
|
254
|
+
to widen the accepted vocabulary from a consumer config. `max-methods-per-class` takes `max`, `no-base-error` takes
|
|
255
|
+
`allow`, and `require-copyright` takes `tags` to adjust their defaults.
|
|
256
|
+
|
|
257
|
+
The base layer also switches on a set of built-in rules: `@typescript-eslint/no-explicit-any`, `max-lines-per-function`
|
|
258
|
+
(50 lines, test code exempt) and `max-depth` (4), plus `eslint-plugin-jsdoc` rules that require a documentation comment
|
|
259
|
+
on every declared function, method and class and forbid types in `@param`/`@returns` (types belong in the signature).
|
|
260
|
+
The type-checked layer adds `@typescript-eslint/explicit-module-boundary-types` and
|
|
261
|
+
`@typescript-eslint/only-throw-error`.
|
|
262
|
+
|
|
189
263
|
## Requirements
|
|
190
264
|
|
|
191
265
|
- PHP ^8.3 (Composer package)
|
package/js/biome.json
CHANGED
|
@@ -60,7 +60,21 @@
|
|
|
60
60
|
"useConst": "warn",
|
|
61
61
|
"useDefaultParameterLast": "warn",
|
|
62
62
|
"useExponentiationOperator": "warn",
|
|
63
|
-
"useNamingConvention":
|
|
63
|
+
"useNamingConvention": {
|
|
64
|
+
"level": "warn",
|
|
65
|
+
"options": {
|
|
66
|
+
"conventions": [
|
|
67
|
+
{
|
|
68
|
+
"selector": { "kind": "objectLiteralProperty" },
|
|
69
|
+
"formats": ["camelCase", "snake_case", "PascalCase"]
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"selector": { "kind": "typeProperty" },
|
|
73
|
+
"formats": ["camelCase", "snake_case", "PascalCase"]
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
},
|
|
64
78
|
"useShorthandAssign": "warn",
|
|
65
79
|
"useSingleVarDeclarator": "warn",
|
|
66
80
|
"useTemplate": "warn",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import jsdoc from 'eslint-plugin-jsdoc';
|
|
2
|
+
import tseslint from 'typescript-eslint';
|
|
3
|
+
import plugin from './plugin.js';
|
|
4
|
+
|
|
5
|
+
const TS_FILES = ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'];
|
|
6
|
+
const TS_AND_JS_FILES = [...TS_FILES, '**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Base flat config: the AST-only custom rules that need no type information.
|
|
10
|
+
*
|
|
11
|
+
* Requires no tsconfig, so it stays cheap. The typescript-eslint parser resolves
|
|
12
|
+
* TypeScript syntax. The interface, readonly-property and enum rules target
|
|
13
|
+
* TypeScript-only constructs; no-mutable-static also applies to plain JavaScript
|
|
14
|
+
* (exported let/var, mutable static fields), so it runs across both. The opt-in
|
|
15
|
+
* type-aware layer lives in ./type-checked.js.
|
|
16
|
+
*
|
|
17
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
18
|
+
* @copyright 2026 Sine Macula Limited
|
|
19
|
+
*/
|
|
20
|
+
export default [
|
|
21
|
+
{
|
|
22
|
+
files: TS_FILES,
|
|
23
|
+
plugins: {
|
|
24
|
+
'@sinemacula': plugin,
|
|
25
|
+
'@typescript-eslint': tseslint.plugin,
|
|
26
|
+
},
|
|
27
|
+
languageOptions: {
|
|
28
|
+
parser: tseslint.parser,
|
|
29
|
+
},
|
|
30
|
+
rules: {
|
|
31
|
+
'@sinemacula/no-interface-prefix': 'error',
|
|
32
|
+
'@sinemacula/require-readonly-public-property': 'error',
|
|
33
|
+
'@sinemacula/valid-enum-member-name': 'error',
|
|
34
|
+
|
|
35
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
files: TS_AND_JS_FILES,
|
|
40
|
+
plugins: {
|
|
41
|
+
'@sinemacula': plugin,
|
|
42
|
+
jsdoc,
|
|
43
|
+
},
|
|
44
|
+
languageOptions: {
|
|
45
|
+
parser: tseslint.parser,
|
|
46
|
+
},
|
|
47
|
+
rules: {
|
|
48
|
+
'@sinemacula/no-mutable-static': 'error',
|
|
49
|
+
'@sinemacula/max-methods-per-class': 'error',
|
|
50
|
+
'@sinemacula/no-base-error': 'error',
|
|
51
|
+
'@sinemacula/require-copyright': 'error',
|
|
52
|
+
|
|
53
|
+
'max-lines-per-function': ['error', { max: 50, skipComments: true, skipBlankLines: true, IIFEs: true }],
|
|
54
|
+
'max-depth': ['error', 4],
|
|
55
|
+
|
|
56
|
+
// Every declared function, method, class and assigned arrow carries a
|
|
57
|
+
// documentation comment describing intent; types live in the signature,
|
|
58
|
+
// never in the comment (no @param/@returns type tags).
|
|
59
|
+
'jsdoc/require-jsdoc': ['error', {
|
|
60
|
+
require: {
|
|
61
|
+
ClassDeclaration: true,
|
|
62
|
+
ClassExpression: true,
|
|
63
|
+
FunctionDeclaration: true,
|
|
64
|
+
MethodDefinition: true,
|
|
65
|
+
},
|
|
66
|
+
contexts: [
|
|
67
|
+
'VariableDeclarator > ArrowFunctionExpression',
|
|
68
|
+
'VariableDeclarator > FunctionExpression',
|
|
69
|
+
],
|
|
70
|
+
checkConstructors: false,
|
|
71
|
+
}],
|
|
72
|
+
'jsdoc/require-description': 'error',
|
|
73
|
+
'jsdoc/no-types': 'error',
|
|
74
|
+
'jsdoc/require-param-description': 'error',
|
|
75
|
+
'jsdoc/require-returns-description': 'error',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
files: ['**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}', '**/__tests__/**', '**/tests/**'],
|
|
80
|
+
rules: {
|
|
81
|
+
'max-lines-per-function': 'off',
|
|
82
|
+
'max-depth': 'off',
|
|
83
|
+
'jsdoc/require-jsdoc': 'off',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import booleanMethodName from './rules/boolean-method-name.js';
|
|
2
|
+
import maxMethodsPerClass from './rules/max-methods-per-class.js';
|
|
3
|
+
import noBaseError from './rules/no-base-error.js';
|
|
4
|
+
import noInterfacePrefix from './rules/no-interface-prefix.js';
|
|
5
|
+
import noMutableStatic from './rules/no-mutable-static.js';
|
|
6
|
+
import requireCopyright from './rules/require-copyright.js';
|
|
7
|
+
import requireReadonlyPublicProperty from './rules/require-readonly-public-property.js';
|
|
8
|
+
import validEnumMemberName from './rules/valid-enum-member-name.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The @sinemacula ESLint plugin.
|
|
12
|
+
*
|
|
13
|
+
* Bundles this package's custom structural rules. Each rule is registered in
|
|
14
|
+
* the map below; the flat configs in index.js and type-checked.js are what
|
|
15
|
+
* switch them on.
|
|
16
|
+
*
|
|
17
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
18
|
+
* @copyright 2026 Sine Macula Limited
|
|
19
|
+
*/
|
|
20
|
+
export default {
|
|
21
|
+
meta: {
|
|
22
|
+
name: '@sinemacula/coding-standards',
|
|
23
|
+
},
|
|
24
|
+
rules: {
|
|
25
|
+
'no-interface-prefix': noInterfacePrefix,
|
|
26
|
+
'boolean-method-name': booleanMethodName,
|
|
27
|
+
'require-readonly-public-property': requireReadonlyPublicProperty,
|
|
28
|
+
'valid-enum-member-name': validEnumMemberName,
|
|
29
|
+
'no-mutable-static': noMutableStatic,
|
|
30
|
+
'max-methods-per-class': maxMethodsPerClass,
|
|
31
|
+
'no-base-error': noBaseError,
|
|
32
|
+
'require-copyright': requireCopyright,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
3
|
+
import { createRule, isAmbient } from './lib.js';
|
|
4
|
+
|
|
5
|
+
/** Copular and modal prefixes that read as predicates. */
|
|
6
|
+
const ALLOWED_PREFIXES = new Set([
|
|
7
|
+
'is', 'are', 'was', 'were', 'has', 'have', 'had', 'can', 'could',
|
|
8
|
+
'should', 'shall', 'will', 'would', 'may', 'might', 'must', 'needs',
|
|
9
|
+
'does',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
/** Idiomatic predicate first words that need no is/has/can prefix. */
|
|
13
|
+
const ALLOWED_PREDICATES = new Set(['successful']);
|
|
14
|
+
|
|
15
|
+
/** Imperative command verbs that may return a result bool. */
|
|
16
|
+
const COMMAND_VERBS = new Set([
|
|
17
|
+
'execute', 'run', 'handle', 'process', 'perform', 'persist', 'save',
|
|
18
|
+
'store', 'write', 'read', 'load', 'fetch', 'delete', 'remove', 'forget',
|
|
19
|
+
'flush', 'purge', 'clear', 'reset', 'refresh', 'sync', 'send', 'dispatch',
|
|
20
|
+
'emit', 'apply', 'guard', 'validate', 'verify', 'authorize', 'ensure',
|
|
21
|
+
'assert', 'register', 'boot', 'build', 'make', 'resolve', 'render',
|
|
22
|
+
'compute', 'calculate', 'expose', 'parse', 'format', 'transform', 'toggle',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** Matches @imperative only at a docblock tag position, never inside prose. */
|
|
26
|
+
const IMPERATIVE_TAG = /^[ \t*]*@imperative(?![-\w])/im;
|
|
27
|
+
|
|
28
|
+
/** The leading camelCase word of a method name. */
|
|
29
|
+
function firstWord(name) {
|
|
30
|
+
const match = /^[a-z]+/.exec(name);
|
|
31
|
+
|
|
32
|
+
return match ? match[0] : '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the name reads as a predicate: a copular/modal prefix, an idiomatic
|
|
37
|
+
* predicate, or a verb ending in `s` (third-person) or `ed` (past tense).
|
|
38
|
+
*/
|
|
39
|
+
function isPredicate(name, prefixes, predicates) {
|
|
40
|
+
const first = firstWord(name);
|
|
41
|
+
|
|
42
|
+
return prefixes.has(first)
|
|
43
|
+
|| predicates.has(first)
|
|
44
|
+
|| first.endsWith('s')
|
|
45
|
+
|| first.endsWith('ed');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether the name is an imperative command verb, not a predicate. */
|
|
49
|
+
function isCommandVerb(name, verbs) {
|
|
50
|
+
return verbs.has(firstWord(name));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a return type resolves to boolean, ignoring a nullable `?bool`-style
|
|
55
|
+
* null/undefined/void tail so an optional boolean still counts. Promise wrappers
|
|
56
|
+
* are unwrapped before this is called, so an awaited boolean counts too.
|
|
57
|
+
*/
|
|
58
|
+
function returnsBoolean(type) {
|
|
59
|
+
if (type.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (type.isUnion()) {
|
|
64
|
+
const parts = type.types.filter(
|
|
65
|
+
part => (part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) === 0,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return parts.length > 0 && parts.every(returnsBoolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether the node is a function expression carrying an inspectable signature. */
|
|
75
|
+
function isFunctionExpression(node) {
|
|
76
|
+
return node != null
|
|
77
|
+
&& (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The static name of a member key, or null when it is not statically named. */
|
|
81
|
+
function keyName(keyNode) {
|
|
82
|
+
if (keyNode.type === 'Identifier' || keyNode.type === 'PrivateIdentifier') {
|
|
83
|
+
return keyNode.name;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (keyNode.type === 'Literal' && typeof keyNode.value === 'string') {
|
|
87
|
+
return keyNode.value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Peel `as`/`satisfies`/non-null wrappers off an expression to reach the value. */
|
|
94
|
+
function unwrapExpression(node) {
|
|
95
|
+
let current = node;
|
|
96
|
+
|
|
97
|
+
while (
|
|
98
|
+
current
|
|
99
|
+
&& (current.type === 'TSAsExpression'
|
|
100
|
+
|| current.type === 'TSSatisfiesExpression'
|
|
101
|
+
|| current.type === 'TSNonNullExpression')
|
|
102
|
+
) {
|
|
103
|
+
current = current.expression;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return current;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether an @imperative opt-out tag precedes the member, including a docblock
|
|
111
|
+
* tucked between a decorator and the member name.
|
|
112
|
+
*/
|
|
113
|
+
function hasImperativeTag(sourceCode, docHost, nameNode) {
|
|
114
|
+
const before = sourceCode.getCommentsBefore(docHost).at(-1);
|
|
115
|
+
|
|
116
|
+
if (before?.type === 'Block' && IMPERATIVE_TAG.test(before.value)) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (docHost.decorators?.length) {
|
|
121
|
+
const inner = sourceCode.getCommentsBefore(nameNode).at(-1);
|
|
122
|
+
|
|
123
|
+
return inner?.type === 'Block' && IMPERATIVE_TAG.test(inner.value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Report the name when it neither reads as a predicate nor is exempt and the
|
|
131
|
+
* resolved (awaited) return type is boolean. Type-predicate guards are predicates
|
|
132
|
+
* by structure and left alone.
|
|
133
|
+
*/
|
|
134
|
+
function inspect(state, nameNode, name, fnNode, docHost) {
|
|
135
|
+
const { checker, services, context, sourceCode } = state;
|
|
136
|
+
|
|
137
|
+
if (
|
|
138
|
+
name.startsWith('__')
|
|
139
|
+
|| isPredicate(name, state.prefixes, state.predicates)
|
|
140
|
+
|| isCommandVerb(name, state.commandVerbs)
|
|
141
|
+
|| hasImperativeTag(sourceCode, docHost, nameNode)
|
|
142
|
+
) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const signature = checker.getSignatureFromDeclaration(services.esTreeNodeToTSNodeMap.get(fnNode));
|
|
147
|
+
|
|
148
|
+
if (!signature || checker.getTypePredicateOfSignature(signature)) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const returnType = checker.getReturnTypeOfSignature(signature);
|
|
153
|
+
|
|
154
|
+
if (!returnsBoolean(checker.getAwaitedType(returnType) ?? returnType)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
context.report({ node: nameNode, messageId: 'notPredicate', data: { name } });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Guard a statically named function value (arrow field, object method or const
|
|
163
|
+
* binding) down to a named key, then inspect it.
|
|
164
|
+
*/
|
|
165
|
+
function inspectFunctionValue(state, keyNode, valueNode, docHost) {
|
|
166
|
+
const name = keyName(keyNode);
|
|
167
|
+
|
|
168
|
+
if (name !== null) {
|
|
169
|
+
inspect(state, keyNode, name, valueNode, docHost);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Guard a class member down to a statically named, non-accessor method, then
|
|
175
|
+
* inspect it. A bodiless member is dropped as an overload signature so the
|
|
176
|
+
* implementation reports once, unless it is abstract or ambient, where the
|
|
177
|
+
* bodiless declaration is the real thing to check.
|
|
178
|
+
*/
|
|
179
|
+
function inspectMember(state, node, allowEmptyBody) {
|
|
180
|
+
if (node.computed || node.kind !== 'method') {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const name = keyName(node.key);
|
|
185
|
+
|
|
186
|
+
if (name === null) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!allowEmptyBody && node.value.body === null && !isAmbient(node, state.context.filename)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
inspect(state, node.key, name, node, node);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The visitor: inspect each named function, method, signature and function-valued
|
|
199
|
+
* member for a boolean return that does not read as a predicate.
|
|
200
|
+
*/
|
|
201
|
+
function buildListeners(state) {
|
|
202
|
+
return {
|
|
203
|
+
FunctionDeclaration(node) {
|
|
204
|
+
if (node.id) {
|
|
205
|
+
inspect(state, node.id, node.id.name, node, node);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
TSDeclareFunction(node) {
|
|
209
|
+
if (node.id && isAmbient(node, state.context.filename)) {
|
|
210
|
+
inspect(state, node.id, node.id.name, node, node);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
MethodDefinition(node) {
|
|
214
|
+
inspectMember(state, node, false);
|
|
215
|
+
},
|
|
216
|
+
TSAbstractMethodDefinition(node) {
|
|
217
|
+
inspectMember(state, node, true);
|
|
218
|
+
},
|
|
219
|
+
TSMethodSignature(node) {
|
|
220
|
+
if (!node.computed && node.kind === 'method') {
|
|
221
|
+
const name = keyName(node.key);
|
|
222
|
+
|
|
223
|
+
if (name !== null) {
|
|
224
|
+
inspect(state, node.key, name, node, node);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
PropertyDefinition(node) {
|
|
229
|
+
const value = unwrapExpression(node.value);
|
|
230
|
+
|
|
231
|
+
if (!node.computed && isFunctionExpression(value)) {
|
|
232
|
+
inspectFunctionValue(state, node.key, value, node);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
Property(node) {
|
|
236
|
+
const value = unwrapExpression(node.value);
|
|
237
|
+
|
|
238
|
+
if (!node.computed && node.kind === 'init' && isFunctionExpression(value)) {
|
|
239
|
+
inspectFunctionValue(state, node.key, value, node);
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
VariableDeclarator(node) {
|
|
243
|
+
const init = unwrapExpression(node.init);
|
|
244
|
+
|
|
245
|
+
if (isFunctionExpression(init)) {
|
|
246
|
+
inspectFunctionValue(state, node.id, init, node.parent);
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Boolean method name rule.
|
|
254
|
+
*
|
|
255
|
+
* A function, method, arrow-bound class field, object method or interface/type
|
|
256
|
+
* signature returning boolean should read as a predicate. A name is accepted when
|
|
257
|
+
* its first camelCase word is a copular or modal prefix (is, has, can, ...), an
|
|
258
|
+
* idiomatic predicate from ALLOWED_PREDICATES (e.g. successful), or a verb ending
|
|
259
|
+
* in `s` (third-person: permits, passes) or `ed` (past tense: succeeded, failed,
|
|
260
|
+
* expired). An imperative command verb (execute, persist, guard, ...) that returns
|
|
261
|
+
* a result bool is exempt via COMMAND_VERBS. A member may also opt out with an
|
|
262
|
+
* @imperative docblock tag. Accessors, the constructor, computed names, magic
|
|
263
|
+
* names and type-predicate guards (x is T) are exempt. The return type is resolved
|
|
264
|
+
* from type information - inferred booleans and awaited Promise<boolean> included -
|
|
265
|
+
* so the rule degrades to a no-op when no type information is available. The
|
|
266
|
+
* accepted vocabulary can be widened per consumer through the rule options.
|
|
267
|
+
*
|
|
268
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
269
|
+
* @copyright 2026 Sine Macula Limited
|
|
270
|
+
*/
|
|
271
|
+
export default createRule({
|
|
272
|
+
name: 'boolean-method-name',
|
|
273
|
+
meta: {
|
|
274
|
+
type: 'suggestion',
|
|
275
|
+
docs: {
|
|
276
|
+
description: 'Require an interrogative prefix on methods and functions that return boolean.',
|
|
277
|
+
},
|
|
278
|
+
schema: [{
|
|
279
|
+
type: 'object',
|
|
280
|
+
properties: {
|
|
281
|
+
additionalPrefixes: { type: 'array', items: { type: 'string' } },
|
|
282
|
+
additionalPredicates: { type: 'array', items: { type: 'string' } },
|
|
283
|
+
additionalCommandVerbs: { type: 'array', items: { type: 'string' } },
|
|
284
|
+
},
|
|
285
|
+
additionalProperties: false,
|
|
286
|
+
}],
|
|
287
|
+
messages: {
|
|
288
|
+
notPredicate: 'Boolean method "{{ name }}" should read as a predicate (is/has/can/...).',
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
defaultOptions: [{ additionalPrefixes: [], additionalPredicates: [], additionalCommandVerbs: [] }],
|
|
292
|
+
create(context, [options]) {
|
|
293
|
+
const services = ESLintUtils.getParserServices(context, true);
|
|
294
|
+
|
|
295
|
+
// Without a type-checker program the return type can't be resolved, so
|
|
296
|
+
// the rule cannot decide anything; degrade to a no-op rather than throw.
|
|
297
|
+
if (!services.program) {
|
|
298
|
+
return {};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Merge consumer additions onto the defaults so a downstream ruleset can
|
|
302
|
+
// widen the accepted vocabulary without losing the built-in words.
|
|
303
|
+
const state = {
|
|
304
|
+
context,
|
|
305
|
+
services,
|
|
306
|
+
sourceCode: context.sourceCode,
|
|
307
|
+
checker: services.program.getTypeChecker(),
|
|
308
|
+
prefixes: new Set([...ALLOWED_PREFIXES, ...options.additionalPrefixes]),
|
|
309
|
+
predicates: new Set([...ALLOWED_PREDICATES, ...options.additionalPredicates]),
|
|
310
|
+
commandVerbs: new Set([...COMMAND_VERBS, ...options.additionalCommandVerbs]),
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return buildListeners(state);
|
|
314
|
+
},
|
|
315
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the @sinemacula ESLint rules.
|
|
3
|
+
*
|
|
4
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
5
|
+
* @copyright 2026 Sine Macula Limited
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
9
|
+
|
|
10
|
+
/** Shared rule factory linking each rule to its documentation anchor. */
|
|
11
|
+
export const createRule = ESLintUtils.RuleCreator(
|
|
12
|
+
name => `https://github.com/sinemacula/coding-standards#${name}`,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
/** Whether the file is a TypeScript declaration file (.d.ts, .d.mts, .d.cts). */
|
|
16
|
+
export function isDeclarationFile(filename) {
|
|
17
|
+
return /\.d\.[cm]?ts$/.test(filename);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether the file path marks it as test code, widening the tests/ directory
|
|
22
|
+
* convention to the .test/.spec suffixes and the __tests__ directory.
|
|
23
|
+
*/
|
|
24
|
+
export function isTestPath(filename) {
|
|
25
|
+
const path = filename.replace(/\\/g, '/');
|
|
26
|
+
|
|
27
|
+
return path.includes('/tests/')
|
|
28
|
+
|| path.includes('/__tests__/')
|
|
29
|
+
|| /\.(test|spec)\.[cm]?[jt]sx?$/.test(path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a node sits in an ambient context: a declaration file, or inside a
|
|
34
|
+
* `declare` class, namespace or block, where a declaration describes existing
|
|
35
|
+
* shape rather than creating runtime state.
|
|
36
|
+
*/
|
|
37
|
+
export function isAmbient(node, filename) {
|
|
38
|
+
if (isDeclarationFile(filename)) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (let current = node; current; current = current.parent) {
|
|
43
|
+
if (current.declare === true) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The nearest enclosing class of a node, or null when it sits outside one. */
|
|
52
|
+
export function nearestClass(ancestors) {
|
|
53
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
54
|
+
if (ancestors[i].type === 'ClassDeclaration' || ancestors[i].type === 'ClassExpression') {
|
|
55
|
+
return ancestors[i];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The simple name of a class's parent, or null when it has none or comes from a
|
|
64
|
+
* computed expression. A qualified parent (`ns.Model`) reduces to its final
|
|
65
|
+
* segment; a mixin-produced base (`mixin(Base)`) has no name.
|
|
66
|
+
*/
|
|
67
|
+
export function superClassName(klass) {
|
|
68
|
+
const parent = klass.superClass;
|
|
69
|
+
|
|
70
|
+
if (parent?.type === 'Identifier') {
|
|
71
|
+
return parent.name;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (parent?.type === 'MemberExpression' && parent.property.type === 'Identifier') {
|
|
75
|
+
return parent.property.name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether the class reads as a test class (by its own or its parent's name). */
|
|
82
|
+
export function isTestClass(klass) {
|
|
83
|
+
if (klass.id?.name?.endsWith('Test')) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parent = superClassName(klass);
|
|
88
|
+
|
|
89
|
+
return parent !== null && parent.endsWith('TestCase');
|
|
90
|
+
}
|