@sinemacula/coding-standards 1.8.4 → 1.9.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.
@@ -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
+ }
@@ -0,0 +1,46 @@
1
+ import { createRule } from './lib.js';
2
+
3
+ /** The pattern a valid enum member name must match. */
4
+ const NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
5
+
6
+ /**
7
+ * Enum member naming rule.
8
+ *
9
+ * Ensures every enum member is declared in SCREAMING_SNAKE_CASE, matching the
10
+ * Sine Macula house convention for enum members. Only real `enum` declarations
11
+ * are checked; object-literal "as const" pseudo-enums are out of scope.
12
+ *
13
+ * @author Ben Carey <bdmc@sinemacula.co.uk>
14
+ * @copyright 2026 Sine Macula Limited
15
+ */
16
+ export default createRule({
17
+ name: 'valid-enum-member-name',
18
+ meta: {
19
+ type: 'suggestion',
20
+ docs: {
21
+ description: 'Require enum members to be declared in SCREAMING_SNAKE_CASE.',
22
+ },
23
+ schema: [],
24
+ messages: {
25
+ notUpperSnakeCase: 'Enum member "{{ name }}" must be declared in SCREAMING_SNAKE_CASE.',
26
+ },
27
+ },
28
+ defaultOptions: [],
29
+ create(context) {
30
+ // Only enum members are visited; a switch `case` is a SwitchCase node
31
+ // and never reaches this visitor.
32
+ return {
33
+ TSEnumMember(node) {
34
+ const id = node.id;
35
+ // A member name is either a bare identifier or a string literal;
36
+ // a string literal is checked by its resolved value, not the raw
37
+ // source text.
38
+ const name = id.type === 'Identifier' ? id.name : String(id.value);
39
+
40
+ if (!NAME_PATTERN.test(name)) {
41
+ context.report({ node: id, messageId: 'notUpperSnakeCase', data: { name } });
42
+ }
43
+ },
44
+ };
45
+ },
46
+ });
@@ -0,0 +1,41 @@
1
+ import tseslint from 'typescript-eslint';
2
+ import base from './index.js';
3
+ import plugin from './plugin.js';
4
+
5
+ /**
6
+ * Opt-in type-aware layer: the cross-file and type-driven rules. Spreads the
7
+ * base config, then turns on type information via the project service so the
8
+ * type-aware custom rules and the curated typescript-eslint rules can resolve.
9
+ * Layered on only where a consumer tsconfig exists, keeping the base config the
10
+ * cheap fast path.
11
+ *
12
+ * @author Ben Carey <bdmc@sinemacula.co.uk>
13
+ * @copyright 2026 Sine Macula Limited
14
+ */
15
+ export default [
16
+ ...base,
17
+ {
18
+ files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
19
+ plugins: {
20
+ '@sinemacula': plugin,
21
+ '@typescript-eslint': tseslint.plugin,
22
+ },
23
+ languageOptions: {
24
+ parser: tseslint.parser,
25
+ parserOptions: {
26
+ projectService: true,
27
+ },
28
+ },
29
+ rules: {
30
+ '@sinemacula/boolean-method-name': 'error',
31
+
32
+ '@typescript-eslint/await-thenable': 'error',
33
+ '@typescript-eslint/consistent-type-imports': 'error',
34
+ '@typescript-eslint/explicit-module-boundary-types': 'error',
35
+ '@typescript-eslint/no-floating-promises': 'error',
36
+ '@typescript-eslint/no-misused-promises': 'error',
37
+ '@typescript-eslint/only-throw-error': 'error',
38
+ // '@typescript-eslint/strict-boolean-expressions': 'error',
39
+ },
40
+ },
41
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sinemacula/coding-standards",
3
- "version": "1.8.4",
3
+ "version": "1.9.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>",
@@ -15,10 +15,12 @@
15
15
  "keywords": [
16
16
  "biome",
17
17
  "knip",
18
+ "eslint",
18
19
  "coding-standards",
19
20
  "linting",
20
21
  "config"
21
22
  ],
23
+ "type": "module",
22
24
  "files": [
23
25
  "js/",
24
26
  "markdown/",
@@ -27,8 +29,47 @@
27
29
  "security/",
28
30
  "README.md",
29
31
  "LICENSE",
30
- "NOTICE"
32
+ "NOTICE",
33
+ "!js/eslint/rules/__tests__"
31
34
  ],
35
+ "exports": {
36
+ "./js/eslint": "./js/eslint/index.js",
37
+ "./js/eslint/type-checked": "./js/eslint/type-checked.js",
38
+ "./*": "./*"
39
+ },
40
+ "scripts": {
41
+ "test:js": "vitest run",
42
+ "lint:js": "eslint js/eslint"
43
+ },
44
+ "devDependencies": {
45
+ "@typescript-eslint/rule-tester": "^8.0.0",
46
+ "@typescript-eslint/utils": "^8.0.0",
47
+ "eslint": "^9.0.0",
48
+ "eslint-plugin-jsdoc": "^63.0.13",
49
+ "typescript": "^5.0.0",
50
+ "typescript-eslint": "^8.0.0",
51
+ "vitest": "^3.0.0"
52
+ },
53
+ "peerDependencies": {
54
+ "eslint": ">=9",
55
+ "eslint-plugin-jsdoc": ">=48",
56
+ "typescript": ">=4.8.4",
57
+ "typescript-eslint": "^8"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "eslint": {
61
+ "optional": true
62
+ },
63
+ "eslint-plugin-jsdoc": {
64
+ "optional": true
65
+ },
66
+ "typescript": {
67
+ "optional": true
68
+ },
69
+ "typescript-eslint": {
70
+ "optional": true
71
+ }
72
+ },
32
73
  "publishConfig": {
33
74
  "access": "public",
34
75
  "registry": "https://registry.npmjs.org/"