@sinemacula/coding-standards 1.11.0 → 1.12.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 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]`. |
@@ -283,17 +285,23 @@ type-checked layer.
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`. |
285
287
  | `@sinemacula/align-doc-tags` | `@author` and `@copyright` values line up at a single column; autofixable. |
288
+ | `@sinemacula/single-line-property-doc` | A data member's documentation comment sits on one line; autofixable. |
289
+ | `@sinemacula/multiline-function-doc` | A method's documentation comment spans multiple lines; autofixable. |
286
290
 
287
291
  `boolean-method-name` takes `additionalPrefixes`, `additionalPredicates` and `additionalCommandVerbs` (string arrays)
288
292
  to widen the accepted vocabulary from a consumer config. `max-methods-per-class` takes `max`, `no-base-error` takes
289
293
  `allow`, and `require-copyright` takes `tags` to adjust their defaults. `align-doc-tags` takes `tags` and `column`,
290
294
  the column counting from the `@`, so the default of 14 gives `@author` six spaces and `@copyright` three.
295
+ Together `single-line-property-doc` and `multiline-function-doc` set a member's comment shape by its kind: data
296
+ members (interface property signatures, enum members and data class fields) take one line, while methods, interface
297
+ method signatures and class fields holding a function take several. A data comment is never required, only held to
298
+ one line where present; a free function keeps the freedom of either shape.
291
299
 
292
300
  The base layer also switches on a set of built-in rules: `@typescript-eslint/no-explicit-any`, `max-lines-per-function`
293
301
  (50 lines, test code exempt) and `max-depth` (4), plus `eslint-plugin-jsdoc` rules that require a documentation comment
294
- on every declared function, method and class, forbid types in `@param`/`@returns` (the tags themselves are welcome,
295
- types belong in the signature) and keep a blank line above every documentation block, single-line blocks included.
296
- The type-checked layer adds `@typescript-eslint/explicit-module-boundary-types` and
302
+ on every declared function, method, class, interface member and class field, forbid types in `@param`/`@returns` (the
303
+ tags themselves are welcome, types belong in the signature) and keep a blank line above every documentation block,
304
+ single-line blocks included. The type-checked layer adds `@typescript-eslint/explicit-module-boundary-types` and
297
305
  `@typescript-eslint/only-throw-error`.
298
306
 
299
307
  ## Requirements
@@ -50,16 +50,20 @@ export default [
50
50
  '@sinemacula/no-base-error': 'error',
51
51
  '@sinemacula/require-copyright': 'error',
52
52
  '@sinemacula/align-doc-tags': 'error',
53
+ '@sinemacula/single-line-property-doc': 'error',
54
+ '@sinemacula/multiline-function-doc': 'error',
53
55
 
54
56
  'max-lines-per-function': ['error', { max: 50, skipComments: true, skipBlankLines: true, IIFEs: true }],
55
57
  'max-depth': ['error', 4],
56
58
 
57
- // Every declared function, method, class and assigned arrow carries
58
- // a documentation comment describing intent. Types live in the
59
- // signature, so a tag never annotates one: the @param and @returns
60
- // tags themselves are welcome, only their type forms are not. Each
61
- // block stands off from the code above it, single-line blocks
62
- // included.
59
+ // Every declared function, method, class, assigned arrow and
60
+ // property carries a documentation comment describing intent, so a
61
+ // reader meets each member's purpose before its type. Interface
62
+ // members and class fields are held to the same bar as methods.
63
+ // Types live in the signature, so a tag never annotates one: the
64
+ // @param and @returns tags themselves are welcome, only their type
65
+ // forms are not. Each block stands off from the code above it,
66
+ // single-line blocks included.
63
67
  'jsdoc/require-jsdoc': ['error', {
64
68
  require: {
65
69
  ClassDeclaration: true,
@@ -70,8 +74,15 @@ export default [
70
74
  contexts: [
71
75
  'VariableDeclarator > ArrowFunctionExpression',
72
76
  'VariableDeclarator > FunctionExpression',
77
+ 'TSPropertySignature',
78
+ 'TSMethodSignature',
79
+ 'PropertyDefinition',
73
80
  ],
74
81
  checkConstructors: false,
82
+ // Report only; the autofix inserts an empty stub that then
83
+ // fails require-description, so a blind fix would scatter
84
+ // hollow comments rather than resolve the finding.
85
+ enableFixer: false,
75
86
  }],
76
87
  'jsdoc/require-description': 'error',
77
88
  'jsdoc/no-types': 'error',
@@ -1,11 +1,13 @@
1
1
  import alignDocTags from './rules/align-doc-tags.js';
2
2
  import booleanMethodName from './rules/boolean-method-name.js';
3
3
  import maxMethodsPerClass from './rules/max-methods-per-class.js';
4
+ import multilineFunctionDoc from './rules/multiline-function-doc.js';
4
5
  import noBaseError from './rules/no-base-error.js';
5
6
  import noInterfacePrefix from './rules/no-interface-prefix.js';
6
7
  import noMutableStatic from './rules/no-mutable-static.js';
7
8
  import requireCopyright from './rules/require-copyright.js';
8
9
  import requireReadonlyPublicProperty from './rules/require-readonly-public-property.js';
10
+ import singleLinePropertyDoc from './rules/single-line-property-doc.js';
9
11
  import validEnumMemberName from './rules/valid-enum-member-name.js';
10
12
 
11
13
  /**
@@ -32,5 +34,7 @@ export default {
32
34
  'no-base-error': noBaseError,
33
35
  'require-copyright': requireCopyright,
34
36
  'align-doc-tags': alignDocTags,
37
+ 'single-line-property-doc': singleLinePropertyDoc,
38
+ 'multiline-function-doc': multilineFunctionDoc,
35
39
  },
36
40
  };
@@ -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. Accessors, the constructor, computed names, magic
270
- * names and type-predicate guards (x is T) are exempt. The return type is
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,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.11.0",
3
+ "version": "1.12.1",
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>",