@sinemacula/coding-standards 1.10.0 → 1.12.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 +16 -3
- package/js/eslint/index.js +20 -3
- package/js/eslint/plugin.js +8 -0
- package/js/eslint/rules/align-doc-tags.js +125 -0
- package/js/eslint/rules/boolean-method-name.js +15 -2
- package/js/eslint/rules/multiline-function-doc.js +103 -0
- package/js/eslint/rules/pad-block-start.js +85 -0
- package/js/eslint/rules/single-line-property-doc.js +107 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -242,10 +242,12 @@ native directive - `// phpcs:ignore <code>` for a sniff, `@phpstan-ignore <ident
|
|
|
242
242
|
| `SineMacula.Classes.RequireReadonlyPublicProperty` | Public properties (declared or promoted) must be `readonly`. |
|
|
243
243
|
| `SineMacula.Commenting.CommentLineLength` | Standalone comment lines must not exceed 80 chars (FQCN/URL exempt). |
|
|
244
244
|
| `SineMacula.Commenting.ConsistentEnumCaseComments` | Enum case docs are all-or-nothing within an enum. |
|
|
245
|
+
| `SineMacula.Commenting.MultilineMethodComment` | A method's doc comment must span multiple lines. |
|
|
245
246
|
| `SineMacula.Commenting.RequireConstantComment` | Every class/interface/enum/trait constant needs a doc comment. |
|
|
246
247
|
| `SineMacula.Commenting.RequireCopyrightTag` | Class/interface/enum/trait docblocks must carry an `@copyright` tag. |
|
|
247
248
|
| `SineMacula.Commenting.RequireNonPromotedParameterComment` | Plain params mixed with promoted ones need a comment. |
|
|
248
249
|
| `SineMacula.Commenting.RequirePromotedPropertyComment` | Every constructor-promoted property needs a doc comment. |
|
|
250
|
+
| `SineMacula.Commenting.SingleLineMemberComment` | A property, constant or enum-case doc comment sits on one line. |
|
|
249
251
|
| `SineMacula.Exceptions.DisallowBaseException` | No throwing the base `\Exception`; throw a domain exception. |
|
|
250
252
|
| `SineMacula.Exceptions.RequireEmptyCatchComment` | An empty catch block must comment its intentional swallow. |
|
|
251
253
|
| `SineMacula.Functions.RequireSensitiveParameter` | Secret-named params need `#[\SensitiveParameter]`. |
|
|
@@ -282,15 +284,26 @@ type-checked layer.
|
|
|
282
284
|
| `@sinemacula/max-methods-per-class` | A single class may declare at most 20 methods; test code exempt. |
|
|
283
285
|
| `@sinemacula/no-base-error` | Throw a domain-specific `Error` subclass, never the base `Error`; test code exempt. |
|
|
284
286
|
| `@sinemacula/require-copyright` | Every file must carry a documentation comment with `@copyright` and `@author`. |
|
|
287
|
+
| `@sinemacula/align-doc-tags` | `@author` and `@copyright` values line up at a single column; autofixable. |
|
|
288
|
+
| `@sinemacula/pad-block-start` | An interface, class or enum body opens with a blank line; autofixable. |
|
|
289
|
+
| `@sinemacula/single-line-property-doc` | A data member's documentation comment sits on one line; autofixable. |
|
|
290
|
+
| `@sinemacula/multiline-function-doc` | A method's documentation comment spans multiple lines; autofixable. |
|
|
285
291
|
|
|
286
292
|
`boolean-method-name` takes `additionalPrefixes`, `additionalPredicates` and `additionalCommandVerbs` (string arrays)
|
|
287
293
|
to widen the accepted vocabulary from a consumer config. `max-methods-per-class` takes `max`, `no-base-error` takes
|
|
288
|
-
`allow`, and `require-copyright` takes `tags` to adjust their defaults.
|
|
294
|
+
`allow`, and `require-copyright` takes `tags` to adjust their defaults. `align-doc-tags` takes `tags` and `column`,
|
|
295
|
+
the column counting from the `@`, so the default of 14 gives `@author` six spaces and `@copyright` three.
|
|
296
|
+
`pad-block-start` governs the opening brace only, leaving the closing brace against the final member. Together
|
|
297
|
+
`single-line-property-doc` and `multiline-function-doc` set a member's comment shape by its kind: data members
|
|
298
|
+
(interface property signatures, enum members and data class fields) take one line, while methods, interface method
|
|
299
|
+
signatures and class fields holding a function take several. A data comment is never required, only held to one line
|
|
300
|
+
where present; a free function keeps the freedom of either shape.
|
|
289
301
|
|
|
290
302
|
The base layer also switches on a set of built-in rules: `@typescript-eslint/no-explicit-any`, `max-lines-per-function`
|
|
291
303
|
(50 lines, test code exempt) and `max-depth` (4), plus `eslint-plugin-jsdoc` rules that require a documentation comment
|
|
292
|
-
on every declared function, method and class
|
|
293
|
-
|
|
304
|
+
on every declared function, method, class, interface member and class field, forbid types in `@param`/`@returns` (the
|
|
305
|
+
tags themselves are welcome, types belong in the signature) and keep a blank line above every documentation block,
|
|
306
|
+
single-line blocks included. The type-checked layer adds `@typescript-eslint/explicit-module-boundary-types` and
|
|
294
307
|
`@typescript-eslint/only-throw-error`.
|
|
295
308
|
|
|
296
309
|
## Requirements
|
package/js/eslint/index.js
CHANGED
|
@@ -49,13 +49,22 @@ export default [
|
|
|
49
49
|
'@sinemacula/max-methods-per-class': 'error',
|
|
50
50
|
'@sinemacula/no-base-error': 'error',
|
|
51
51
|
'@sinemacula/require-copyright': 'error',
|
|
52
|
+
'@sinemacula/align-doc-tags': 'error',
|
|
53
|
+
'@sinemacula/pad-block-start': 'error',
|
|
54
|
+
'@sinemacula/single-line-property-doc': 'error',
|
|
55
|
+
'@sinemacula/multiline-function-doc': 'error',
|
|
52
56
|
|
|
53
57
|
'max-lines-per-function': ['error', { max: 50, skipComments: true, skipBlankLines: true, IIFEs: true }],
|
|
54
58
|
'max-depth': ['error', 4],
|
|
55
59
|
|
|
56
|
-
// Every declared function, method, class
|
|
57
|
-
// a documentation comment describing intent
|
|
58
|
-
//
|
|
60
|
+
// Every declared function, method, class, assigned arrow and
|
|
61
|
+
// property carries a documentation comment describing intent, so a
|
|
62
|
+
// reader meets each member's purpose before its type. Interface
|
|
63
|
+
// members and class fields are held to the same bar as methods.
|
|
64
|
+
// Types live in the signature, so a tag never annotates one: the
|
|
65
|
+
// @param and @returns tags themselves are welcome, only their type
|
|
66
|
+
// forms are not. Each block stands off from the code above it,
|
|
67
|
+
// single-line blocks included.
|
|
59
68
|
'jsdoc/require-jsdoc': ['error', {
|
|
60
69
|
require: {
|
|
61
70
|
ClassDeclaration: true,
|
|
@@ -66,13 +75,21 @@ export default [
|
|
|
66
75
|
contexts: [
|
|
67
76
|
'VariableDeclarator > ArrowFunctionExpression',
|
|
68
77
|
'VariableDeclarator > FunctionExpression',
|
|
78
|
+
'TSPropertySignature',
|
|
79
|
+
'TSMethodSignature',
|
|
80
|
+
'PropertyDefinition',
|
|
69
81
|
],
|
|
70
82
|
checkConstructors: false,
|
|
83
|
+
// Report only; the autofix inserts an empty stub that then
|
|
84
|
+
// fails require-description, so a blind fix would scatter
|
|
85
|
+
// hollow comments rather than resolve the finding.
|
|
86
|
+
enableFixer: false,
|
|
71
87
|
}],
|
|
72
88
|
'jsdoc/require-description': 'error',
|
|
73
89
|
'jsdoc/no-types': 'error',
|
|
74
90
|
'jsdoc/require-param-description': 'error',
|
|
75
91
|
'jsdoc/require-returns-description': 'error',
|
|
92
|
+
'jsdoc/lines-before-block': ['error', { lines: 1, ignoreSingleLines: false }],
|
|
76
93
|
},
|
|
77
94
|
},
|
|
78
95
|
{
|
package/js/eslint/plugin.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import alignDocTags from './rules/align-doc-tags.js';
|
|
1
2
|
import booleanMethodName from './rules/boolean-method-name.js';
|
|
2
3
|
import maxMethodsPerClass from './rules/max-methods-per-class.js';
|
|
4
|
+
import multilineFunctionDoc from './rules/multiline-function-doc.js';
|
|
3
5
|
import noBaseError from './rules/no-base-error.js';
|
|
4
6
|
import noInterfacePrefix from './rules/no-interface-prefix.js';
|
|
5
7
|
import noMutableStatic from './rules/no-mutable-static.js';
|
|
8
|
+
import padBlockStart from './rules/pad-block-start.js';
|
|
6
9
|
import requireCopyright from './rules/require-copyright.js';
|
|
7
10
|
import requireReadonlyPublicProperty from './rules/require-readonly-public-property.js';
|
|
11
|
+
import singleLinePropertyDoc from './rules/single-line-property-doc.js';
|
|
8
12
|
import validEnumMemberName from './rules/valid-enum-member-name.js';
|
|
9
13
|
|
|
10
14
|
/**
|
|
@@ -30,5 +34,9 @@ export default {
|
|
|
30
34
|
'max-methods-per-class': maxMethodsPerClass,
|
|
31
35
|
'no-base-error': noBaseError,
|
|
32
36
|
'require-copyright': requireCopyright,
|
|
37
|
+
'align-doc-tags': alignDocTags,
|
|
38
|
+
'pad-block-start': padBlockStart,
|
|
39
|
+
'single-line-property-doc': singleLinePropertyDoc,
|
|
40
|
+
'multiline-function-doc': multilineFunctionDoc,
|
|
33
41
|
},
|
|
34
42
|
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createRule } from './lib.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TAGS = ['author', 'copyright'];
|
|
4
|
+
const DEFAULT_COLUMN = 14;
|
|
5
|
+
|
|
6
|
+
/** A documentation line opening a tag that carries a value. */
|
|
7
|
+
const TAG_LINE = /^(\s*\*\s*)@([A-Za-z][\w-]*)([^\S\n]+)(?=\S)/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The spaces that place a tag's value at the target column, or null when the
|
|
11
|
+
* tag is too long to reach it.
|
|
12
|
+
*/
|
|
13
|
+
function padding(tag, column) {
|
|
14
|
+
const spaces = column - tag.length - 2;
|
|
15
|
+
|
|
16
|
+
return spaces > 0 ? ' '.repeat(spaces) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Align the values of the documentation tags a file's header declares,
|
|
21
|
+
* `@author` and `@copyright` by default.
|
|
22
|
+
*
|
|
23
|
+
* The tags a file must carry read as a block, so their values line up at a
|
|
24
|
+
* single column rather than sitting at whatever offset each tag's own length
|
|
25
|
+
* happens to produce. The column counts from the `@`, which at the default of
|
|
26
|
+
* 14 gives `@author` six spaces and `@copyright` three.
|
|
27
|
+
*
|
|
28
|
+
* Only the run of whitespace between a listed tag and its value is considered,
|
|
29
|
+
* and only on the line the tag opens; a wrapped value's continuation lines, a
|
|
30
|
+
* tag with no value and every unlisted tag are left alone. A tag longer than
|
|
31
|
+
* the column can accommodate is skipped rather than reported, as no spacing
|
|
32
|
+
* would satisfy the requirement. Presence of the tags is a separate concern,
|
|
33
|
+
* enforced by `require-copyright`.
|
|
34
|
+
*
|
|
35
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
36
|
+
* @copyright 2026 Sine Macula Limited
|
|
37
|
+
*/
|
|
38
|
+
export default createRule({
|
|
39
|
+
name: 'align-doc-tags',
|
|
40
|
+
meta: {
|
|
41
|
+
type: 'layout',
|
|
42
|
+
fixable: 'whitespace',
|
|
43
|
+
docs: {
|
|
44
|
+
description: 'Align the values of the documentation tags a file header declares.',
|
|
45
|
+
},
|
|
46
|
+
schema: [
|
|
47
|
+
{
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
tags: {
|
|
51
|
+
type: 'array',
|
|
52
|
+
items: { type: 'string' },
|
|
53
|
+
},
|
|
54
|
+
column: {
|
|
55
|
+
type: 'integer',
|
|
56
|
+
minimum: 1,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
messages: {
|
|
63
|
+
misaligned: 'The @{{ tag }} value must start at column {{ column }}.',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
defaultOptions: [{ tags: DEFAULT_TAGS, column: DEFAULT_COLUMN }],
|
|
67
|
+
create(context, [options]) {
|
|
68
|
+
const { sourceCode } = context;
|
|
69
|
+
const tags = new Set((options.tags ?? DEFAULT_TAGS).map(tag => tag.toLowerCase()));
|
|
70
|
+
const column = options.column ?? DEFAULT_COLUMN;
|
|
71
|
+
|
|
72
|
+
/** Report and fix the spacing a single tag line carries. */
|
|
73
|
+
function inspect(line, start) {
|
|
74
|
+
const match = TAG_LINE.exec(line);
|
|
75
|
+
|
|
76
|
+
if (!match) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [, prefix, tag, spacing] = match;
|
|
81
|
+
|
|
82
|
+
if (!tags.has(tag.toLowerCase())) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const desired = padding(tag, column);
|
|
87
|
+
|
|
88
|
+
if (desired === null || spacing === desired) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const from = start + prefix.length + 1 + tag.length;
|
|
93
|
+
const to = from + spacing.length;
|
|
94
|
+
|
|
95
|
+
context.report({
|
|
96
|
+
loc: {
|
|
97
|
+
start: sourceCode.getLocFromIndex(from),
|
|
98
|
+
end: sourceCode.getLocFromIndex(to),
|
|
99
|
+
},
|
|
100
|
+
messageId: 'misaligned',
|
|
101
|
+
data: { tag, column },
|
|
102
|
+
fix: fixer => fixer.replaceTextRange([from, to], desired),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
Program() {
|
|
108
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
109
|
+
if (comment.type !== 'Block' || !comment.value.startsWith('*')) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The comment's value begins after the opening `/*`, so an
|
|
114
|
+
// offset within it maps onto the source two characters in.
|
|
115
|
+
let offset = comment.range[0] + 2;
|
|
116
|
+
|
|
117
|
+
for (const line of comment.value.split('\n')) {
|
|
118
|
+
inspect(line, offset);
|
|
119
|
+
offset += line.length + 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
});
|
|
@@ -20,8 +20,12 @@ const COMMAND_VERBS = new Set([
|
|
|
20
20
|
'emit', 'apply', 'guard', 'validate', 'verify', 'authorize', 'ensure',
|
|
21
21
|
'assert', 'register', 'boot', 'build', 'make', 'resolve', 'render',
|
|
22
22
|
'compute', 'calculate', 'expose', 'parse', 'format', 'transform', 'toggle',
|
|
23
|
+
'submit', 'confirm', 'copy', 'attempt',
|
|
23
24
|
]);
|
|
24
25
|
|
|
26
|
+
/** Method names that are a primitive type keyword: typed accessors, not predicates. */
|
|
27
|
+
const TYPE_ACCESSOR_NAMES = new Set(['boolean']);
|
|
28
|
+
|
|
25
29
|
/** Matches @imperative only at a docblock tag position, never inside prose. */
|
|
26
30
|
const IMPERATIVE_TAG = /^[ \t*]*@imperative(?![-\w])/im;
|
|
27
31
|
|
|
@@ -50,6 +54,11 @@ function isCommandVerb(name, verbs) {
|
|
|
50
54
|
return verbs.has(firstWord(name));
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/** Whether the name is an on* event-handler callback (onError, onUnauthorized). */
|
|
58
|
+
function isEventHandler(name) {
|
|
59
|
+
return /^on[A-Z]/.test(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
/**
|
|
54
63
|
* Whether a return type resolves to boolean, ignoring a nullable `?bool`-style
|
|
55
64
|
* null/undefined/void tail so an optional boolean still counts. Promise
|
|
@@ -143,6 +152,8 @@ function inspect(state, nameNode, name, fnNode, docHost) {
|
|
|
143
152
|
name.startsWith('__')
|
|
144
153
|
|| isPredicate(name, state.prefixes, state.predicates)
|
|
145
154
|
|| isCommandVerb(name, state.commandVerbs)
|
|
155
|
+
|| isEventHandler(name)
|
|
156
|
+
|| TYPE_ACCESSOR_NAMES.has(name)
|
|
146
157
|
|| hasImperativeTag(sourceCode, docHost, nameNode)
|
|
147
158
|
) {
|
|
148
159
|
return;
|
|
@@ -266,8 +277,10 @@ function buildListeners(state) {
|
|
|
266
277
|
* succeeded, failed, expired). An imperative command verb (execute, persist,
|
|
267
278
|
* guard, ...) that returns a result bool is exempt via COMMAND_VERBS. A member
|
|
268
279
|
* may also opt out with an
|
|
269
|
-
* @imperative docblock tag.
|
|
270
|
-
*
|
|
280
|
+
* @imperative docblock tag. An on* event-handler callback (onError) and a
|
|
281
|
+
* method named after a primitive type (a typed accessor such as boolean())
|
|
282
|
+
* are exempt, as are accessors, the constructor, computed names, magic names
|
|
283
|
+
* and type-predicate guards (x is T). The return type is
|
|
271
284
|
* resolved from type information - inferred booleans and awaited
|
|
272
285
|
* Promise<boolean> included - so the rule degrades to a no-op when no type
|
|
273
286
|
* information is available. The accepted vocabulary can be widened per consumer
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createRule } from './lib.js';
|
|
2
|
+
|
|
3
|
+
/** Values that make a class field read as behaviour rather than data. */
|
|
4
|
+
const FUNCTION_VALUES = new Set(['ArrowFunctionExpression', 'FunctionExpression']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the node declares a method or a method-like member: a class method,
|
|
8
|
+
* an interface method signature, or a class field that holds a function.
|
|
9
|
+
*/
|
|
10
|
+
function isMethodMember(node) {
|
|
11
|
+
switch (node.type) {
|
|
12
|
+
case 'MethodDefinition':
|
|
13
|
+
case 'TSAbstractMethodDefinition':
|
|
14
|
+
case 'TSMethodSignature':
|
|
15
|
+
return true;
|
|
16
|
+
case 'PropertyDefinition':
|
|
17
|
+
return Boolean(node.value) && FUNCTION_VALUES.has(node.value.type);
|
|
18
|
+
default:
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The single line of prose a one-line documentation block carries. */
|
|
24
|
+
function singleLineContent(value) {
|
|
25
|
+
return value.replace(/^\*/, '').trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Require a method's documentation comment to span multiple lines.
|
|
30
|
+
*
|
|
31
|
+
* A method earns a fuller account than a data field: what it does, and in time
|
|
32
|
+
* its parameters and what it returns. Holding its comment open across lines
|
|
33
|
+
* keeps that room ready and sets behaviour apart from data at a glance, the
|
|
34
|
+
* mirror of the single-line form data properties take. Class methods, interface
|
|
35
|
+
* method signatures and class fields that hold a function are governed; a data
|
|
36
|
+
* property keeps its one line, and a free function is left to the author, since
|
|
37
|
+
* a short helper reads well on a single line.
|
|
38
|
+
*
|
|
39
|
+
* A block already spanning lines passes. A one-line block is unfolded onto
|
|
40
|
+
* three, its prose moved to the middle line; an empty one is left for the
|
|
41
|
+
* description rule to report, since there is nothing to unfold.
|
|
42
|
+
*
|
|
43
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
44
|
+
* @copyright 2026 Sine Macula Limited
|
|
45
|
+
*/
|
|
46
|
+
export default createRule({
|
|
47
|
+
name: 'multiline-function-doc',
|
|
48
|
+
meta: {
|
|
49
|
+
type: 'layout',
|
|
50
|
+
fixable: 'whitespace',
|
|
51
|
+
docs: {
|
|
52
|
+
description: 'Require a method documentation comment to span multiple lines.',
|
|
53
|
+
},
|
|
54
|
+
schema: [],
|
|
55
|
+
messages: {
|
|
56
|
+
singleLine: 'A method documentation comment must span multiple lines.',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
defaultOptions: [],
|
|
60
|
+
create(context) {
|
|
61
|
+
const { sourceCode } = context;
|
|
62
|
+
|
|
63
|
+
/** Unfold a method's one-line documentation block onto three lines. */
|
|
64
|
+
function inspect(node) {
|
|
65
|
+
if (!isMethodMember(node)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const doc = sourceCode.getCommentsBefore(node).at(-1);
|
|
70
|
+
|
|
71
|
+
if (!doc || doc.type !== 'Block' || !doc.value.startsWith('*')) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (doc.loc.start.line !== doc.loc.end.line) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const content = singleLineContent(doc.value);
|
|
80
|
+
|
|
81
|
+
// An empty block is the description rule's to report; there is no
|
|
82
|
+
// prose to move onto the middle line.
|
|
83
|
+
if (content.length === 0) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const pad = ' '.repeat(doc.loc.start.column);
|
|
88
|
+
|
|
89
|
+
context.report({
|
|
90
|
+
node: doc,
|
|
91
|
+
messageId: 'singleLine',
|
|
92
|
+
fix: fixer => fixer.replaceText(doc, `/**\n${pad} * ${content}\n${pad} */`),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
MethodDefinition: inspect,
|
|
98
|
+
TSAbstractMethodDefinition: inspect,
|
|
99
|
+
TSMethodSignature: inspect,
|
|
100
|
+
PropertyDefinition: inspect,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createRule } from './lib.js';
|
|
2
|
+
|
|
3
|
+
/** The member-carrying bodies whose first line is held clear of the brace. */
|
|
4
|
+
const BODY_TYPES = new Set(['TSInterfaceBody', 'ClassBody', 'TSEnumBody']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Require a blank line after the opening brace of an interface, class or enum
|
|
8
|
+
* body.
|
|
9
|
+
*
|
|
10
|
+
* The first member then stands off from the declaration the way every later
|
|
11
|
+
* member stands off from the one above it, so a body reads as an evenly spaced
|
|
12
|
+
* list rather than crowding its opening line. Only the opening brace is
|
|
13
|
+
* governed; the closing brace is left to sit against the final member.
|
|
14
|
+
*
|
|
15
|
+
* An empty body and a body written entirely on one line carry no member to
|
|
16
|
+
* separate and are left alone. Where the first member opens with a
|
|
17
|
+
* documentation comment the blank line falls above the comment, so the comment
|
|
18
|
+
* stays attached to what it documents.
|
|
19
|
+
*
|
|
20
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
21
|
+
* @copyright 2026 Sine Macula Limited
|
|
22
|
+
*/
|
|
23
|
+
export default createRule({
|
|
24
|
+
name: 'pad-block-start',
|
|
25
|
+
meta: {
|
|
26
|
+
type: 'layout',
|
|
27
|
+
fixable: 'whitespace',
|
|
28
|
+
docs: {
|
|
29
|
+
description: 'Require a blank line after the opening brace of an interface, class or enum body.',
|
|
30
|
+
},
|
|
31
|
+
schema: [],
|
|
32
|
+
messages: {
|
|
33
|
+
missing: 'A body must begin with a blank line after its opening brace.',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
defaultOptions: [],
|
|
37
|
+
create(context) {
|
|
38
|
+
const { sourceCode } = context;
|
|
39
|
+
|
|
40
|
+
/** Hold the first member of a body clear of the opening brace. */
|
|
41
|
+
function inspect(node) {
|
|
42
|
+
const brace = sourceCode.getFirstToken(node);
|
|
43
|
+
const close = sourceCode.getLastToken(node);
|
|
44
|
+
|
|
45
|
+
// A one-line body has no member to separate from the brace.
|
|
46
|
+
if (!brace || !close || brace.loc.end.line === close.loc.start.line) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const first = sourceCode.getTokenAfter(brace, { includeComments: true });
|
|
51
|
+
|
|
52
|
+
// An empty body carries nothing to stand off from the brace.
|
|
53
|
+
if (!first || first === close) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const blanks = first.loc.start.line - brace.loc.end.line - 1;
|
|
58
|
+
|
|
59
|
+
if (blanks === 1) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const onOwnLine = first.loc.start.line > brace.loc.end.line;
|
|
64
|
+
|
|
65
|
+
context.report({
|
|
66
|
+
loc: brace.loc,
|
|
67
|
+
messageId: 'missing',
|
|
68
|
+
fix: onOwnLine
|
|
69
|
+
? fixer => fixer.replaceTextRange(
|
|
70
|
+
[brace.range[1], first.range[0]],
|
|
71
|
+
`\n\n${' '.repeat(first.loc.start.column)}`,
|
|
72
|
+
)
|
|
73
|
+
: null,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const visitor = {};
|
|
78
|
+
|
|
79
|
+
for (const type of BODY_TYPES) {
|
|
80
|
+
visitor[type] = inspect;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return visitor;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createRule } from './lib.js';
|
|
2
|
+
|
|
3
|
+
/** Class-field values that read as behaviour rather than data. */
|
|
4
|
+
const FUNCTION_VALUES = new Set(['ArrowFunctionExpression', 'FunctionExpression']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the node is a data member: an interface property signature, an enum
|
|
8
|
+
* member, or a class field that does not hold a function.
|
|
9
|
+
*/
|
|
10
|
+
function isDataProperty(node) {
|
|
11
|
+
if (node.type === 'TSPropertySignature' || node.type === 'TSEnumMember') {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return node.type === 'PropertyDefinition'
|
|
16
|
+
&& !(node.value && FUNCTION_VALUES.has(node.value.type));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The text lines a documentation block carries, each stripped of its leading
|
|
21
|
+
* margin and asterisk, with blank lines dropped.
|
|
22
|
+
*/
|
|
23
|
+
function contentLines(value) {
|
|
24
|
+
return value
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map(line => line.replace(/^\s*\*?[^\S\n]?/, '').trimEnd())
|
|
27
|
+
.filter(line => line.length > 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Require a data member's documentation comment to sit on a single line.
|
|
32
|
+
*
|
|
33
|
+
* A field's description is a single phrase, so it belongs on one line however
|
|
34
|
+
* long it runs; nothing wraps a comment to a width, and breaking it by hand
|
|
35
|
+
* only scatters the phrase down the file. Interface property signatures, enum
|
|
36
|
+
* members and class fields that hold data are governed; a class field holding a
|
|
37
|
+
* function reads as behaviour, may document parameters and is left to span as
|
|
38
|
+
* many lines as it needs, as are methods and every free function. A comment is
|
|
39
|
+
* never required here, only held to one line where it is present, so enum
|
|
40
|
+
* members stay documented at the author's discretion.
|
|
41
|
+
*
|
|
42
|
+
* A block already on one line passes. A multi-line block collapses to one, its
|
|
43
|
+
* lines joined by single spaces, provided it carries prose alone: a block
|
|
44
|
+
* bearing a tag such as `@deprecated` keeps its shape and is reported without a
|
|
45
|
+
* fix, since folding a tag into a running line would change its meaning.
|
|
46
|
+
*
|
|
47
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
48
|
+
* @copyright 2026 Sine Macula Limited
|
|
49
|
+
*/
|
|
50
|
+
export default createRule({
|
|
51
|
+
name: 'single-line-property-doc',
|
|
52
|
+
meta: {
|
|
53
|
+
type: 'layout',
|
|
54
|
+
fixable: 'whitespace',
|
|
55
|
+
docs: {
|
|
56
|
+
description: 'Require a data member documentation comment to sit on a single line.',
|
|
57
|
+
},
|
|
58
|
+
schema: [],
|
|
59
|
+
messages: {
|
|
60
|
+
multiline: 'A data member documentation comment must sit on a single line.',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
defaultOptions: [],
|
|
64
|
+
create(context) {
|
|
65
|
+
const { sourceCode } = context;
|
|
66
|
+
|
|
67
|
+
/** Fold a data property's leading documentation block onto one line. */
|
|
68
|
+
function inspect(node) {
|
|
69
|
+
if (!isDataProperty(node)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const comments = sourceCode.getCommentsBefore(node);
|
|
74
|
+
const doc = comments.at(-1);
|
|
75
|
+
|
|
76
|
+
if (!doc || doc.type !== 'Block' || !doc.value.startsWith('*')) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (doc.loc.start.line === doc.loc.end.line) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const lines = contentLines(doc.value);
|
|
85
|
+
|
|
86
|
+
// An empty block is the description rule's to report; there is no
|
|
87
|
+
// prose to fold onto one line.
|
|
88
|
+
if (lines.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hasTag = lines.some(line => line.startsWith('@'));
|
|
93
|
+
|
|
94
|
+
context.report({
|
|
95
|
+
node: doc,
|
|
96
|
+
messageId: 'multiline',
|
|
97
|
+
fix: hasTag ? null : fixer => fixer.replaceText(doc, `/** ${lines.join(' ')} */`),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
TSPropertySignature: inspect,
|
|
103
|
+
TSEnumMember: inspect,
|
|
104
|
+
PropertyDefinition: inspect,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sinemacula/coding-standards",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Centralized coding standards, static analysis configurations, and code quality tooling for all Sine Macula repositories.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Ben Carey <bdmc@sinemacula.co.uk>",
|