@sinemacula/coding-standards 1.8.3 → 1.9.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/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`. |
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)
@@ -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
+ }