@sinemacula/coding-standards 1.22.0 → 1.23.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
@@ -417,19 +417,30 @@ type-checked layer. Every rule is scoped to `.ts`/`.js`; `comment-line-wrap` alo
417
417
  | `@sinemacula/max-methods-per-class` | A single class may declare at most 20 methods; test code exempt. |
418
418
  | `@sinemacula/no-base-error` | Throw a domain-specific `Error` subclass, never the base `Error`; test code exempt. |
419
419
  | `@sinemacula/require-copyright` | Every file must carry a documentation comment with `@copyright` and `@author`. |
420
+ | `@sinemacula/require-file-description` | The same comment must open with a prose summary above those tags. |
420
421
  | `@sinemacula/align-doc-tags` | `@author` and `@copyright` values line up at a single column; autofixable. |
421
422
  | `@sinemacula/single-line-property-doc` | A data member's documentation comment sits on one line; autofixable. |
422
423
  | `@sinemacula/multiline-function-doc` | A method's documentation comment spans multiple lines; autofixable. |
423
424
  | `@sinemacula/comment-line-wrap` | Standalone comment prose wrapped to 80 chars, YAML included; premature wraps too. |
424
425
 
425
- `boolean-method-name` takes `additionalPrefixes`, `additionalPredicates` and `additionalCommandVerbs` (string arrays)
426
- to widen the accepted vocabulary from a consumer config. `max-methods-per-class` takes `max`, `no-base-error` takes
427
- `allow`, and `require-copyright` takes `tags` to adjust their defaults. `align-doc-tags` takes `tags` and `column`, the
428
- column counting from the `@`, so the default of 14 gives `@author` six spaces and `@copyright` three. Together
429
- `single-line-property-doc` and `multiline-function-doc` set a member's comment shape by its kind: data members
430
- (interface property signatures, enum members and data class fields) take one line, while methods, interface method
431
- signatures and class fields holding a function take several. A data comment is never required, only held to one line
432
- where present; a free function keeps the freedom of either shape.
426
+ `boolean-method-name` takes `additionalPrefixes`, `additionalPredicates` and `additionalCommandVerbs` (string arrays) to
427
+ widen the accepted vocabulary from a consumer config. `max-methods-per-class` takes `max`, `no-base-error` takes
428
+ `allow`, and `require-copyright` and `require-file-description` each take `tags` to adjust their defaults.
429
+ `align-doc-tags` takes `tags` and `column`, the column counting from the `@`, so the default of 14 gives `@author` six
430
+ spaces and `@copyright` three. Together `single-line-property-doc` and `multiline-function-doc` set a member's comment
431
+ shape by its kind: data members (interface property signatures, enum members and data class fields) take one line, while
432
+ methods, interface method signatures and class fields holding a function take several. A data comment is never required,
433
+ only held to one line where present; a free function keeps the freedom of either shape.
434
+
435
+ `require-copyright` and `require-file-description` divide the file header between them: the first asks that a single
436
+ block comment carry `@copyright` and `@author`, the second that the same block open with a prose summary above them,
437
+ which is the shape the convention has always described and the half nothing enforced. Both find that block by the
438
+ tags it carries rather than by its position, so a module documented at its export below the imports is read as the
439
+ header exactly as a comment at the top of the file is; a project that narrows one rule's `tags` should narrow the
440
+ other's to match. Only the block's first non-blank line is read, and only for whether it is prose rather than a tag,
441
+ which puts the summary above the tags and stops a wrapped tag value's continuation line passing as one. What the
442
+ summary says is the author's business. A file carrying no such block at all is left to `require-copyright`, so one
443
+ missing header is never faulted twice.
433
444
 
434
445
  `comment-line-wrap` takes `maxLength` (default 80) and is the syntax-only counterpart of the PHP
435
446
  `SineMacula.Commenting.CommentLineLength` sniff. It fills standalone `//` and `#` runs and multi-line docblock prose
@@ -450,10 +461,18 @@ than comment, so a shell comment inside a `run: |` step is never seen, and a com
450
461
 
451
462
  The base layer also switches on a set of built-in rules: `@typescript-eslint/no-explicit-any`, `curly` (a brace on every
452
463
  control statement, as PSR-12 already requires on the PHP side), `max-lines-per-function` (50 lines, test code exempt)
453
- and `max-depth` (4), plus `eslint-plugin-jsdoc` rules that require a documentation comment on every declared function,
454
- method, class, interface member and class field, require a description on every `@param` and `@returns`, and keep a
455
- blank line above every documentation block, single-line blocks included. The type-checked layer adds
456
- `@typescript-eslint/explicit-module-boundary-types` and `@typescript-eslint/only-throw-error`.
464
+ and `max-depth` (4, test code exempt), plus `eslint-plugin-jsdoc` rules that require a documentation comment on every
465
+ declared function, method, class, interface member and class field, require a description on every `@param` and
466
+ `@returns`, and keep a blank line above every documentation block, single-line blocks included. The type-checked layer
467
+ adds `@typescript-eslint/explicit-module-boundary-types` and `@typescript-eslint/only-throw-error`.
468
+
469
+ Test files - `*.test.*`, `*.spec.*` and anything under `__tests__/`, `tests/` or `test-support/` - are exempt from
470
+ `max-lines-per-function` and `max-depth` alone. A spec body is a long, deeply nested account of one scenario, and
471
+ splitting it to satisfy a metric hides the scenario rather than simplifying it. They are not exempt from documentation:
472
+ `jsdoc/require-jsdoc` reaches declared functions, assigned arrows and classes, which in a spec file are its helpers,
473
+ factories and builders - the code a reader has to understand before the assertions mean anything. A test case is an
474
+ arrow passed as a call argument, which none of the rule's contexts match, so `it('...', () => {})` is never asked to
475
+ carry a block; a spec of test cases alone carries no documentation burden at all.
457
476
 
458
477
  `jsdoc/no-types`, which forbids a type in `@param`/`@returns`, runs over `.ts`/`.tsx`/`.mts`/`.cts` alone, alongside
459
478
  `@typescript-eslint/no-explicit-any`. A TypeScript signature already records the type, so the tag would only repeat it
@@ -63,6 +63,7 @@ export default [
63
63
  '@sinemacula/max-methods-per-class': 'error',
64
64
  '@sinemacula/no-base-error': 'error',
65
65
  '@sinemacula/require-copyright': 'error',
66
+ '@sinemacula/require-file-description': 'error',
66
67
  '@sinemacula/align-doc-tags': 'error',
67
68
  '@sinemacula/single-line-property-doc': 'error',
68
69
  '@sinemacula/multiline-function-doc': 'error',
@@ -125,11 +126,19 @@ export default [
125
126
  },
126
127
  },
127
128
  {
129
+ // Test code keeps the length and depth exemptions and nothing else. A
130
+ // spec body is a long, deeply nested account of one scenario, and
131
+ // splitting it to satisfy a metric hides the scenario rather than
132
+ // simplifying it. Documentation is a different matter: require-jsdoc's
133
+ // contexts reach declared functions, assigned arrows and classes, which
134
+ // in a spec file are its helpers, factories and builders - the code a
135
+ // reader has to understand before the assertions mean anything. A test
136
+ // case itself is an arrow passed as a call argument, which no context
137
+ // reaches, so `it('...', () => {})` is never asked to carry a block.
128
138
  files: ['**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}', '**/__tests__/**', '**/tests/**', '**/test-support/**'],
129
139
  rules: {
130
140
  'max-lines-per-function': 'off',
131
141
  'max-depth': 'off',
132
- 'jsdoc/require-jsdoc': 'off',
133
142
  },
134
143
  },
135
144
  ];
@@ -7,6 +7,7 @@ import noBaseError from './rules/no-base-error.js';
7
7
  import noInterfacePrefix from './rules/no-interface-prefix.js';
8
8
  import noMutableStatic from './rules/no-mutable-static.js';
9
9
  import requireCopyright from './rules/require-copyright.js';
10
+ import requireFileDescription from './rules/require-file-description.js';
10
11
  import requireReadonlyPublicProperty from './rules/require-readonly-public-property.js';
11
12
  import singleLinePropertyDoc from './rules/single-line-property-doc.js';
12
13
  import validEnumMemberName from './rules/valid-enum-member-name.js';
@@ -34,6 +35,7 @@ export default {
34
35
  'max-methods-per-class': maxMethodsPerClass,
35
36
  'no-base-error': noBaseError,
36
37
  'require-copyright': requireCopyright,
38
+ 'require-file-description': requireFileDescription,
37
39
  'align-doc-tags': alignDocTags,
38
40
  'single-line-property-doc': singleLinePropertyDoc,
39
41
  'multiline-function-doc': multilineFunctionDoc,
@@ -92,3 +92,27 @@ export function isTestClass(klass) {
92
92
 
93
93
  return parent !== null && parent.endsWith('TestCase');
94
94
  }
95
+
96
+ /** A boundary-anchored matcher for a documentation tag by its bare name. */
97
+ export function tagMatcher(tag) {
98
+ const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
99
+
100
+ return new RegExp(`(?:^|[\\s*])@${escaped}(?![-\\w])`, 'i');
101
+ }
102
+
103
+ /**
104
+ * The file's descriptive docblock: the block comment carrying all of the given
105
+ * tags together, or null when no comment carries them.
106
+ *
107
+ * The block is found by its tags rather than its position, since the header
108
+ * need not open the file: a module documented at its export sits below the
109
+ * imports. Requiring one comment to carry every tag is what makes the match a
110
+ * single block rather than a header split across several.
111
+ */
112
+ export function fileDocBlock(sourceCode, tags) {
113
+ const matchers = tags.map(tagMatcher);
114
+
115
+ return sourceCode.getAllComments().find(
116
+ comment => comment.type === 'Block' && matchers.every(matcher => matcher.test(comment.value)),
117
+ ) ?? null;
118
+ }
@@ -1,14 +1,7 @@
1
- import { createRule } from './lib.js';
1
+ import { createRule, fileDocBlock } from './lib.js';
2
2
 
3
3
  const DEFAULT_TAGS = ['copyright', 'author'];
4
4
 
5
- /** A boundary-anchored matcher for a documentation tag by its bare name. */
6
- function tagMatcher(tag) {
7
- const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
-
9
- return new RegExp(`(?:^|[\\s*])@${escaped}(?![-\\w])`, 'i');
10
- }
11
-
12
5
  /**
13
6
  * Require a documentation comment carrying the tags every source file must
14
7
  * declare, `@copyright` and `@author` by default.
@@ -17,7 +10,9 @@ function tagMatcher(tag) {
17
10
  * sit inside the file's descriptive docblock alongside its summary rather than
18
11
  * in a separate header. Only the presence of each tag is checked, never its
19
12
  * value or alignment, which are matters of formatting. The required set is
20
- * configurable, so a project may drop `@author` or add tags of its own.
13
+ * configurable, so a project may drop `@author` or add tags of its own. The
14
+ * summary those tags sit beside is the separate concern of
15
+ * `require-file-description`, which locates the same block by the same tags.
21
16
  *
22
17
  * @author Ben Carey <bdmc@sinemacula.co.uk>
23
18
  * @copyright 2026 Sine Macula Limited
@@ -49,15 +44,10 @@ export default createRule({
49
44
  create(context, [options]) {
50
45
  const { sourceCode } = context;
51
46
  const required = options.tags ?? DEFAULT_TAGS;
52
- const matchers = required.map(tagMatcher);
53
47
 
54
48
  return {
55
49
  Program(node) {
56
- const documented = sourceCode.getAllComments().some(
57
- comment => comment.type === 'Block' && matchers.every(matcher => matcher.test(comment.value)),
58
- );
59
-
60
- if (documented) {
50
+ if (fileDocBlock(sourceCode, required) !== null) {
61
51
  return;
62
52
  }
63
53
 
@@ -0,0 +1,97 @@
1
+ import { createRule, fileDocBlock } from './lib.js';
2
+
3
+ const DEFAULT_TAGS = ['copyright', 'author'];
4
+
5
+ /** A line opening a documentation tag rather than carrying prose. */
6
+ const TAG_LINE = /^@[A-Za-z][\w-]*(?:\s|$)/;
7
+
8
+ /** The prose a documentation line carries, with its leading margin removed. */
9
+ function content(line) {
10
+ return line.replace(/^\s*\*\s*/, '').trim();
11
+ }
12
+
13
+ /** Whether the block opens with prose rather than going straight to its tags. */
14
+ function described(comment) {
15
+ for (const line of comment.value.split('\n')) {
16
+ const text = content(line);
17
+
18
+ if (text === '') {
19
+ continue;
20
+ }
21
+
22
+ return !TAG_LINE.test(text);
23
+ }
24
+
25
+ return false;
26
+ }
27
+
28
+ /**
29
+ * Require the file's descriptive docblock to carry a summary, not tags alone.
30
+ *
31
+ * The house convention is a prose summary followed by the tags every file
32
+ * declares, so `@author` and `@copyright` annotate a description rather than
33
+ * standing as a header of their own. `require-copyright` has always asked for
34
+ * the tags; this asks for the sentence they were meant to sit beneath, which
35
+ * was the intent all along and the half nothing enforced. A file whose entire
36
+ * header is two tags says who owns it and nothing about what it is.
37
+ *
38
+ * The block is located exactly as `require-copyright` locates it, by the tags
39
+ * it carries, so the two rules always speak about the same comment; a project
40
+ * changing one rule's `tags` should change the other's to match. A file with no
41
+ * such block is left alone, since `require-copyright` already owns the block's
42
+ * existence and faulting one omission twice would only double the report.
43
+ *
44
+ * Only the block's first non-blank line is read, and only whether it is prose.
45
+ * That places the summary above the tags, as the convention writes it, and
46
+ * keeps a wrapped tag value's continuation line from passing as a description.
47
+ * What the sentence says is the author's business: the rule asserts that prose
48
+ * is there, never that it is good.
49
+ *
50
+ * @author Ben Carey <bdmc@sinemacula.co.uk>
51
+ * @copyright 2026 Sine Macula Limited
52
+ */
53
+ export default createRule({
54
+ name: 'require-file-description',
55
+ meta: {
56
+ type: 'suggestion',
57
+ docs: {
58
+ description: "Require the file's documentation comment to open with a description.",
59
+ },
60
+ schema: [
61
+ {
62
+ type: 'object',
63
+ properties: {
64
+ tags: {
65
+ type: 'array',
66
+ items: { type: 'string' },
67
+ },
68
+ },
69
+ additionalProperties: false,
70
+ },
71
+ ],
72
+ messages: {
73
+ missing: 'The documentation comment carrying {{ tags }} must open with a description.',
74
+ },
75
+ },
76
+ defaultOptions: [{ tags: DEFAULT_TAGS }],
77
+ create(context, [options]) {
78
+ const { sourceCode } = context;
79
+ const required = options.tags ?? DEFAULT_TAGS;
80
+
81
+ return {
82
+ Program() {
83
+ const doc = fileDocBlock(sourceCode, required);
84
+
85
+ if (doc === null || described(doc)) {
86
+ return;
87
+ }
88
+
89
+ context.report({
90
+ node: doc,
91
+ messageId: 'missing',
92
+ data: { tags: required.map(tag => `@${tag}`).join(', ') },
93
+ });
94
+ },
95
+ };
96
+ },
97
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sinemacula/coding-standards",
3
- "version": "1.22.0",
3
+ "version": "1.23.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>",
@@ -58,7 +58,7 @@
58
58
  "eslint-plugin-vue": "^10.0.0",
59
59
  "typescript": "^5.0.0",
60
60
  "typescript-eslint": "^8.0.0",
61
- "vitest": "^3.0.0",
61
+ "vitest": "^4.1.11",
62
62
  "vue-eslint-parser": "^10.0.0",
63
63
  "yaml-eslint-parser": "^2.1.0"
64
64
  },