@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 +75 -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 +113 -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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createRule, isTestClass, isTestPath } from './lib.js';
|
|
2
|
+
|
|
3
|
+
/** Whether a class member counts towards the method total. */
|
|
4
|
+
function isCountedMethod(member) {
|
|
5
|
+
// An overload signature shares its name with the implementation below it, so it
|
|
6
|
+
// carries no body and is not counted a second time.
|
|
7
|
+
return member.type === 'MethodDefinition'
|
|
8
|
+
&& member.value.type !== 'TSEmptyBodyFunctionExpression';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The number of methods declared directly on the class body. */
|
|
12
|
+
function countMethods(node) {
|
|
13
|
+
let count = 0;
|
|
14
|
+
|
|
15
|
+
for (const member of node.body.body) {
|
|
16
|
+
if (isCountedMethod(member)) {
|
|
17
|
+
count++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return count;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cap the number of methods declared directly on a single class.
|
|
26
|
+
*
|
|
27
|
+
* A class that grows past the limit is doing too many jobs and should be split.
|
|
28
|
+
* Every method declared on the class body counts, including the constructor,
|
|
29
|
+
* static methods and get/set accessors; a method spread across overload
|
|
30
|
+
* signatures counts once, through its implementation. Methods on a nested class
|
|
31
|
+
* belong to that class, not the one enclosing it. Test code legitimately gathers
|
|
32
|
+
* many methods on one fixture, so a test file or test class is exempt.
|
|
33
|
+
*
|
|
34
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
35
|
+
* @copyright 2026 Sine Macula Limited
|
|
36
|
+
*/
|
|
37
|
+
export default createRule({
|
|
38
|
+
name: 'max-methods-per-class',
|
|
39
|
+
meta: {
|
|
40
|
+
type: 'suggestion',
|
|
41
|
+
docs: {
|
|
42
|
+
description: 'Limit the number of methods declared on a single class.',
|
|
43
|
+
},
|
|
44
|
+
schema: [
|
|
45
|
+
{
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
max: { type: 'integer', minimum: 0 },
|
|
49
|
+
},
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
messages: {
|
|
54
|
+
tooMany: 'Class declares {{ count }} methods; the maximum is {{ max }}.',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
defaultOptions: [{ max: 20 }],
|
|
58
|
+
create(context, [options]) {
|
|
59
|
+
const { max } = options;
|
|
60
|
+
const { sourceCode } = context;
|
|
61
|
+
|
|
62
|
+
if (isTestPath(context.filename)) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Flag a class whose method count runs past the configured maximum. */
|
|
67
|
+
const inspect = node => {
|
|
68
|
+
if (isTestClass(node)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const count = countMethods(node);
|
|
73
|
+
|
|
74
|
+
if (count <= max) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const target = node.id ?? sourceCode.getFirstToken(node, { filter: token => token.value === 'class' });
|
|
79
|
+
|
|
80
|
+
context.report({
|
|
81
|
+
loc: target.loc,
|
|
82
|
+
messageId: 'tooMany',
|
|
83
|
+
data: { count, max },
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
ClassDeclaration: inspect,
|
|
89
|
+
ClassExpression: inspect,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createRule } from './lib.js';
|
|
2
|
+
|
|
3
|
+
/** Global objects whose `Error` member resolves to the base `Error`. */
|
|
4
|
+
const GLOBAL_OBJECTS = new Set(['globalThis', 'window', 'global', 'self']);
|
|
5
|
+
|
|
6
|
+
/** Strips the `as`, `satisfies` and non-null wrappers a throw argument may carry. */
|
|
7
|
+
function unwrapType(node) {
|
|
8
|
+
let current = node;
|
|
9
|
+
|
|
10
|
+
while (
|
|
11
|
+
current.type === 'TSAsExpression'
|
|
12
|
+
|| current.type === 'TSSatisfiesExpression'
|
|
13
|
+
|| current.type === 'TSNonNullExpression'
|
|
14
|
+
) {
|
|
15
|
+
current = current.expression;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return current;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Whether a node is a reference to one of the recognised global objects. */
|
|
22
|
+
function isGlobalObjectRef(node) {
|
|
23
|
+
return node.type === 'Identifier' && GLOBAL_OBJECTS.has(node.name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a node is the identifier `Error`. */
|
|
27
|
+
function isErrorProperty(node) {
|
|
28
|
+
return node.type === 'Identifier' && node.name === 'Error';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether a member expression reads as `<global>.Error`. */
|
|
32
|
+
function isGlobalError(callee) {
|
|
33
|
+
return callee.type === 'MemberExpression'
|
|
34
|
+
&& !callee.computed
|
|
35
|
+
&& isGlobalObjectRef(callee.object)
|
|
36
|
+
&& isErrorProperty(callee.property);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether a construction callee denotes the base `Error`, directly or through a global object. */
|
|
40
|
+
function isBaseError(callee) {
|
|
41
|
+
if (callee.type === 'Identifier') {
|
|
42
|
+
return callee.name === 'Error';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return isGlobalError(callee);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Disallow throwing the base `Error`.
|
|
50
|
+
*
|
|
51
|
+
* A bare `throw new Error(...)`, including the argument-free `throw new Error`,
|
|
52
|
+
* gives a caller no type to catch on, so a domain-specific subclass such as
|
|
53
|
+
* `ValidationError` must be thrown instead.
|
|
54
|
+
*
|
|
55
|
+
* The check is syntactic. It flags a throw whose argument constructs the base
|
|
56
|
+
* `Error`, whether named directly (`new Error()`) or reached through a global
|
|
57
|
+
* object (`new globalThis.Error()`, `new window.Error()`). A `throw ... as X`,
|
|
58
|
+
* `throw ...!` or `satisfies` annotation is unwrapped before the construction is
|
|
59
|
+
* inspected, so the annotation cannot hide the throw. Subclasses
|
|
60
|
+
* (`new NotFoundError()`) and the specific built-ins (`new TypeError()`) read as
|
|
61
|
+
* domain-specific and pass; a re-thrown variable (`throw err`), a qualified name
|
|
62
|
+
* (`new foo.Error()`) and a base `Error` built for a non-throw use
|
|
63
|
+
* (`const e = new Error()`) fall outside the pattern. Only the base `Error` is
|
|
64
|
+
* ever a candidate, so listing `Error` in the `allow` option is the one way to
|
|
65
|
+
* permit it; any other name has nothing to match and stays inert.
|
|
66
|
+
*
|
|
67
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
68
|
+
* @copyright 2026 Sine Macula Limited
|
|
69
|
+
*/
|
|
70
|
+
export default createRule({
|
|
71
|
+
name: 'no-base-error',
|
|
72
|
+
meta: {
|
|
73
|
+
type: 'problem',
|
|
74
|
+
docs: {
|
|
75
|
+
description: 'Disallow throwing the base Error in favour of a domain-specific subclass.',
|
|
76
|
+
},
|
|
77
|
+
schema: [
|
|
78
|
+
{
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
allow: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
items: { type: 'string' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
messages: {
|
|
90
|
+
baseError: 'Throw a domain-specific Error subclass, not the base Error.',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
defaultOptions: [{ allow: [] }],
|
|
94
|
+
create(context, [options]) {
|
|
95
|
+
const allow = options.allow ?? [];
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ThrowStatement(node) {
|
|
99
|
+
const thrown = unwrapType(node.argument);
|
|
100
|
+
|
|
101
|
+
if (thrown.type !== 'NewExpression' || !isBaseError(thrown.callee)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (allow.includes('Error')) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
context.report({ node: thrown, messageId: 'baseError' });
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createRule } from './lib.js';
|
|
2
|
+
|
|
3
|
+
/** Disallowed "I" prefix: a capital I directly followed by another uppercase letter. */
|
|
4
|
+
const PREFIX_PATTERN = /^I[A-Z]/;
|
|
5
|
+
|
|
6
|
+
/** A global or string-named module block augments types we do not own. */
|
|
7
|
+
const isExternalAugmentation = node =>
|
|
8
|
+
node.type === 'TSModuleDeclaration'
|
|
9
|
+
&& (node.kind === 'global' || node.id.type === 'Literal');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Forbids the Hungarian "I" prefix on interface and type-alias names
|
|
13
|
+
* (IUserRepository), which adds nothing over the language's own type system.
|
|
14
|
+
*
|
|
15
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
16
|
+
* @copyright 2026 Sine Macula Limited
|
|
17
|
+
*/
|
|
18
|
+
export default createRule({
|
|
19
|
+
name: 'no-interface-prefix',
|
|
20
|
+
meta: {
|
|
21
|
+
type: 'problem',
|
|
22
|
+
docs: {
|
|
23
|
+
description: 'Disallow the "I" prefix on interface and type-alias names.',
|
|
24
|
+
},
|
|
25
|
+
schema: [],
|
|
26
|
+
messages: {
|
|
27
|
+
prefixed: '{{ kind }} "{{ name }}" must not use an "I" prefix.',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultOptions: [],
|
|
31
|
+
create(context) {
|
|
32
|
+
const { sourceCode } = context;
|
|
33
|
+
|
|
34
|
+
/** Report a declaration whose name carries the disallowed "I" prefix. */
|
|
35
|
+
const check = (node, kind) => {
|
|
36
|
+
const name = node.id.name;
|
|
37
|
+
|
|
38
|
+
if (!PREFIX_PATTERN.test(name)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Augmenting an external module or the global scope cannot rename it.
|
|
43
|
+
if (sourceCode.getAncestors(node).some(isExternalAugmentation)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
context.report({ node: node.id, messageId: 'prefixed', data: { kind, name } });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
TSInterfaceDeclaration: node => check(node, 'Interface'),
|
|
52
|
+
TSTypeAliasDeclaration: node => check(node, 'Type'),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { ASTUtils } from '@typescript-eslint/utils';
|
|
2
|
+
import { createRule, isAmbient, isDeclarationFile, isTestClass, isTestPath, nearestClass } from './lib.js';
|
|
3
|
+
|
|
4
|
+
/** Unwrap a rest element to its bound target, else return the node unchanged. */
|
|
5
|
+
function restTarget(node) {
|
|
6
|
+
return node.type === 'RestElement' ? node.argument : node;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The destructuring children of a pattern that may bind further identifiers. */
|
|
10
|
+
function patternChildren(pattern) {
|
|
11
|
+
switch (pattern.type) {
|
|
12
|
+
case 'ArrayPattern':
|
|
13
|
+
return pattern.elements.filter(Boolean).map(restTarget);
|
|
14
|
+
case 'ObjectPattern':
|
|
15
|
+
return pattern.properties.map(
|
|
16
|
+
property => (property.type === 'RestElement' ? property.argument : property.value),
|
|
17
|
+
);
|
|
18
|
+
case 'AssignmentPattern':
|
|
19
|
+
return [pattern.left];
|
|
20
|
+
default:
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Collect the bound identifiers of a declarator target, unwrapping destructuring. */
|
|
26
|
+
function boundIdentifiers(pattern, out) {
|
|
27
|
+
if (pattern.type === 'Identifier') {
|
|
28
|
+
out.push(pattern);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const child of patternChildren(pattern)) {
|
|
33
|
+
boundIdentifiers(child, out);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a resolved binding is a mutable local `let`/`var` (not const, class,
|
|
39
|
+
* function, import or an ambient declaration), and so publishes live module state.
|
|
40
|
+
*/
|
|
41
|
+
function bindsMutableVariable(variable, filename) {
|
|
42
|
+
if (isDeclarationFile(filename)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return variable.defs.some(
|
|
47
|
+
def => def.type === 'Variable' && def.parent.declare !== true && def.parent.kind !== 'const',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Matches @managed-static only at a docblock tag position, never inside prose. */
|
|
52
|
+
const MANAGED_TAG = /(?:^|[\s*])@managed-static(?![-\w])/i;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether a @managed-static opt-out docblock precedes the node, including one
|
|
56
|
+
* tucked between a decorator and the member name.
|
|
57
|
+
*/
|
|
58
|
+
function hasManagedTag(node, sourceCode) {
|
|
59
|
+
const before = sourceCode.getCommentsBefore(node).at(-1);
|
|
60
|
+
|
|
61
|
+
if (before?.type === 'Block' && MANAGED_TAG.test(before.value)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A docblock tucked between the decorators and the declaration sits before the
|
|
66
|
+
// first token after the last decorator, not before the whole member.
|
|
67
|
+
if (node.decorators?.length) {
|
|
68
|
+
const afterDecorators = sourceCode.getTokenAfter(node.decorators.at(-1));
|
|
69
|
+
const inner = afterDecorators && sourceCode.getCommentsBefore(afterDecorators).at(-1);
|
|
70
|
+
|
|
71
|
+
return inner?.type === 'Block' && MANAGED_TAG.test(inner.value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A readable name for a class member key, including private and computed forms. */
|
|
78
|
+
function describeKey(node, sourceCode) {
|
|
79
|
+
if (node.computed) {
|
|
80
|
+
return `[${sourceCode.getText(node.key)}]`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (node.key.type === 'PrivateIdentifier') {
|
|
84
|
+
return `#${node.key.name}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (node.key.type === 'Literal') {
|
|
88
|
+
return String(node.key.value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return node.key.name;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Report each identifier bound by an inline `export let`/`export var` declaration. */
|
|
95
|
+
function reportInlineExports(node, context) {
|
|
96
|
+
const declaration = node.declaration;
|
|
97
|
+
|
|
98
|
+
if (declaration.type !== 'VariableDeclaration' || declaration.kind === 'const') {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (isAmbient(declaration, context.filename)) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const identifiers = [];
|
|
107
|
+
|
|
108
|
+
for (const declarator of declaration.declarations) {
|
|
109
|
+
boundIdentifiers(declarator.id, identifiers);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const identifier of identifiers) {
|
|
113
|
+
context.report({ node: identifier, messageId: 'mutableExport', data: { name: identifier.name } });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Report `export { x }` specifiers that publish a mutable local binding. */
|
|
118
|
+
function reportSpecifierExports(node, context) {
|
|
119
|
+
// A re-export carries no local binding; a type-only export carries no runtime one.
|
|
120
|
+
if (node.source || node.exportKind === 'type') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const scope = context.sourceCode.getScope(node);
|
|
125
|
+
|
|
126
|
+
for (const specifier of node.specifiers) {
|
|
127
|
+
if (specifier.exportKind === 'type' || specifier.local.type !== 'Identifier') {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const variable = ASTUtils.findVariable(scope, specifier.local);
|
|
132
|
+
|
|
133
|
+
if (variable && bindsMutableVariable(variable, context.filename)) {
|
|
134
|
+
context.report({ node: specifier.local, messageId: 'mutableExport', data: { name: specifier.local.name } });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Forbids mutable module-level and class-level static state: exported `let`/`var`
|
|
141
|
+
* bindings (declared inline or published through an `export { ... }` specifier)
|
|
142
|
+
* and non-readonly `static` class fields, including `static accessor`
|
|
143
|
+
* auto-accessors. All are global mutable state; `const` and `readonly` express the
|
|
144
|
+
* read-only configuration this allows.
|
|
145
|
+
*
|
|
146
|
+
* The check is syntactic, not write-sensitive: a static is flagged whether or not
|
|
147
|
+
* a reassignment is visible, since cross-file writes are out of a per-file rule's
|
|
148
|
+
* reach. Deliberately mutated statics opt out with a `@managed-static` doc tag on
|
|
149
|
+
* the field or its declaring class, and test classes are exempt. Module scope is
|
|
150
|
+
* limited to exported bindings; an unexported module `let` stays local and is left
|
|
151
|
+
* alone.
|
|
152
|
+
*
|
|
153
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
154
|
+
* @copyright 2026 Sine Macula Limited
|
|
155
|
+
*/
|
|
156
|
+
export default createRule({
|
|
157
|
+
name: 'no-mutable-static',
|
|
158
|
+
meta: {
|
|
159
|
+
type: 'problem',
|
|
160
|
+
docs: {
|
|
161
|
+
description: 'Disallow mutable exported bindings and mutable static class fields.',
|
|
162
|
+
},
|
|
163
|
+
schema: [],
|
|
164
|
+
messages: {
|
|
165
|
+
mutableExport: 'Exported binding "{{ name }}" introduces mutable module state; declare it with const.',
|
|
166
|
+
mutableStatic: 'Static field "{{ name }}" is mutable static state; mark it readonly or use a constant.',
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
defaultOptions: [],
|
|
170
|
+
create(context) {
|
|
171
|
+
const { sourceCode } = context;
|
|
172
|
+
|
|
173
|
+
/** Whether a static member is exempt: test code or an opted-out declaration. */
|
|
174
|
+
const isStaticExempt = node => {
|
|
175
|
+
if (isTestPath(context.filename)) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const klass = nearestClass(sourceCode.getAncestors(node));
|
|
180
|
+
|
|
181
|
+
if (klass !== null && isTestClass(klass)) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return hasManagedTag(node, sourceCode) || (klass !== null && hasManagedTag(klass, sourceCode));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Flag a non-readonly static field or auto-accessor as mutable static state. */
|
|
189
|
+
const inspectStatic = node => {
|
|
190
|
+
if (!node.static || node.readonly || isAmbient(node, context.filename) || isStaticExempt(node)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
context.report({
|
|
195
|
+
node: node.key,
|
|
196
|
+
messageId: 'mutableStatic',
|
|
197
|
+
data: { name: describeKey(node, sourceCode) },
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
ExportNamedDeclaration(node) {
|
|
203
|
+
if (node.declaration) {
|
|
204
|
+
reportInlineExports(node, context);
|
|
205
|
+
} else {
|
|
206
|
+
reportSpecifierExports(node, context);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
PropertyDefinition: inspectStatic,
|
|
210
|
+
AccessorProperty: inspectStatic,
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createRule } from './lib.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TAGS = ['copyright', 'author'];
|
|
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
|
+
/**
|
|
13
|
+
* Require a documentation comment carrying the tags every source file must
|
|
14
|
+
* declare, `@copyright` and `@author` by default.
|
|
15
|
+
*
|
|
16
|
+
* A single block comment must carry all of the required tags together, so they
|
|
17
|
+
* sit inside the file's descriptive docblock alongside its summary rather than
|
|
18
|
+
* in a separate header. Only the presence of each tag is checked, never its
|
|
19
|
+
* 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.
|
|
21
|
+
*
|
|
22
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
23
|
+
* @copyright 2026 Sine Macula Limited
|
|
24
|
+
*/
|
|
25
|
+
export default createRule({
|
|
26
|
+
name: 'require-copyright',
|
|
27
|
+
meta: {
|
|
28
|
+
type: 'suggestion',
|
|
29
|
+
docs: {
|
|
30
|
+
description: 'Require a documentation comment carrying the copyright and author tags.',
|
|
31
|
+
},
|
|
32
|
+
schema: [
|
|
33
|
+
{
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
tags: {
|
|
37
|
+
type: 'array',
|
|
38
|
+
items: { type: 'string' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
messages: {
|
|
45
|
+
missing: 'A documentation comment must carry the {{ tags }} tags.',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
defaultOptions: [{ tags: DEFAULT_TAGS }],
|
|
49
|
+
create(context, [options]) {
|
|
50
|
+
const { sourceCode } = context;
|
|
51
|
+
const required = options.tags ?? DEFAULT_TAGS;
|
|
52
|
+
const matchers = required.map(tagMatcher);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
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) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
context.report({
|
|
65
|
+
node,
|
|
66
|
+
loc: { line: 1, column: 0 },
|
|
67
|
+
messageId: 'missing',
|
|
68
|
+
data: { tags: required.map(tag => `@${tag}`).join(', ') },
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRule,
|
|
3
|
+
isAmbient,
|
|
4
|
+
isDeclarationFile,
|
|
5
|
+
isTestClass,
|
|
6
|
+
isTestPath,
|
|
7
|
+
nearestClass,
|
|
8
|
+
superClassName,
|
|
9
|
+
} from './lib.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Require the `readonly` modifier on public class properties.
|
|
13
|
+
*
|
|
14
|
+
* Public properties, whether declared or constructor-promoted, must be
|
|
15
|
+
* `readonly`. Mutable public state breaks encapsulation; the legitimate
|
|
16
|
+
* data-holder case is expressed with `public readonly`. Public auto-accessors
|
|
17
|
+
* have no `readonly` form and always expose a setter, so they are disallowed
|
|
18
|
+
* outright. Static properties are left to the mutable static state concern,
|
|
19
|
+
* non-public properties are unaffected, and ambient declarations plus test
|
|
20
|
+
* fixtures are exempt. TypeScript has no whole-class readonly modifier, so
|
|
21
|
+
* there is no class-level exemption.
|
|
22
|
+
*
|
|
23
|
+
* @author Ben Carey <bdmc@sinemacula.co.uk>
|
|
24
|
+
* @copyright 2026 Sine Macula Limited
|
|
25
|
+
*/
|
|
26
|
+
export default createRule({
|
|
27
|
+
name: 'require-readonly-public-property',
|
|
28
|
+
meta: {
|
|
29
|
+
type: 'problem',
|
|
30
|
+
docs: {
|
|
31
|
+
description: 'Require the readonly modifier on public class properties.',
|
|
32
|
+
},
|
|
33
|
+
schema: [
|
|
34
|
+
{
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
ignoredParentClasses: {
|
|
38
|
+
type: 'array',
|
|
39
|
+
items: { type: 'string' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
messages: {
|
|
46
|
+
mutable: 'Public property "{{ name }}" must be readonly; mutable public state breaks encapsulation.',
|
|
47
|
+
accessor: 'Public auto-accessor "{{ name }}" is mutable; make it non-public or a readonly property.',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
defaultOptions: [{ ignoredParentClasses: [] }],
|
|
51
|
+
create(context, [options]) {
|
|
52
|
+
const ignoredParents = options.ignoredParentClasses ?? [];
|
|
53
|
+
const { sourceCode } = context;
|
|
54
|
+
|
|
55
|
+
// Declaration files and test directories are exempt wholesale.
|
|
56
|
+
if (isDeclarationFile(context.filename) || isTestPath(context.filename)) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether the node's enclosing class is exempt from the mandate. */
|
|
61
|
+
const isExempt = node => {
|
|
62
|
+
if (isAmbient(node, context.filename)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const klass = nearestClass(sourceCode.getAncestors(node));
|
|
67
|
+
|
|
68
|
+
return klass !== null
|
|
69
|
+
&& (isTestClass(klass) || isIgnoredParent(klass, ignoredParents));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
'PropertyDefinition, TSAbstractPropertyDefinition'(node) {
|
|
74
|
+
if (isOutOfScope(node) || node.readonly || isExempt(node)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
context.report({
|
|
79
|
+
node: node.key,
|
|
80
|
+
messageId: 'mutable',
|
|
81
|
+
data: { name: propertyName(node.key, sourceCode) },
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
'AccessorProperty, TSAbstractAccessorProperty'(node) {
|
|
85
|
+
// An auto-accessor cannot be readonly, so a public one is always mutable.
|
|
86
|
+
if (isOutOfScope(node) || isExempt(node)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
context.report({
|
|
91
|
+
node: node.key,
|
|
92
|
+
messageId: 'accessor',
|
|
93
|
+
data: { name: propertyName(node.key, sourceCode) },
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
TSParameterProperty(node) {
|
|
97
|
+
if (node.readonly || node.accessibility !== 'public' || isExempt(node)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
context.report({
|
|
102
|
+
node: node.parameter,
|
|
103
|
+
messageId: 'mutable',
|
|
104
|
+
data: { name: parameterName(node.parameter) },
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** Whether the property's accessibility marks it non-public. */
|
|
112
|
+
function isNonPublic(node) {
|
|
113
|
+
return node.key.type === 'PrivateIdentifier'
|
|
114
|
+
|| node.accessibility === 'private'
|
|
115
|
+
|| node.accessibility === 'protected';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether a class property is outside the public-mutable scope: static, ambient, or non-public. */
|
|
119
|
+
function isOutOfScope(node) {
|
|
120
|
+
return node.static || node.declare || isNonPublic(node);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether the class extends one of the configured exempt parents. */
|
|
124
|
+
function isIgnoredParent(klass, ignoredParents) {
|
|
125
|
+
const parent = superClassName(klass);
|
|
126
|
+
|
|
127
|
+
return parent !== null && ignoredParents.includes(parent);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A readable name for a (possibly computed) property key. */
|
|
131
|
+
function propertyName(key, sourceCode) {
|
|
132
|
+
if (key.type === 'Identifier') {
|
|
133
|
+
return key.name;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (key.type === 'Literal') {
|
|
137
|
+
return String(key.value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return sourceCode.getText(key);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The declared name of a constructor parameter property. */
|
|
144
|
+
function parameterName(parameter) {
|
|
145
|
+
if (parameter.type === 'Identifier') {
|
|
146
|
+
return parameter.name;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (parameter.type === 'AssignmentPattern' && parameter.left.type === 'Identifier') {
|
|
150
|
+
return parameter.left.name;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
}
|