@sinemacula/coding-standards 1.9.2 → 1.11.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
@@ -121,13 +121,16 @@ typescript-eslint tooling, and this package to your dev dependencies:
121
121
  npm install --save-dev eslint typescript typescript-eslint eslint-plugin-jsdoc @sinemacula/coding-standards
122
122
  ```
123
123
 
124
- The package exposes two flat-config entry points:
124
+ The package exposes three flat-config entry points:
125
125
 
126
126
  - `@sinemacula/coding-standards/js/eslint` - the base layer of syntax-only custom rules; needs no `tsconfig`, so it
127
127
  stays cheap and runs anywhere Biome runs.
128
128
  - `@sinemacula/coding-standards/js/eslint/type-checked` - the opt-in type-aware layer. It includes the base layer and
129
129
  adds the cross-file / type-driven rules, so it needs a consumer `tsconfig`; use it in place of the base layer where
130
130
  one exists.
131
+ - `@sinemacula/coding-standards/js/eslint/vue` - the opt-in Vue layer for single-file components. Unlike the
132
+ type-aware layer it carries no base rules of its own, so spread it *alongside* whichever layer the repository
133
+ already uses rather than in place of one.
131
134
 
132
135
  Create an `eslint.config.js` (or `.qlty/configs/eslint.config.js` when wired through Qlty) that spreads the layer you
133
136
  want. Without a `tsconfig`, use the base layer:
@@ -146,6 +149,25 @@ import typeChecked from '@sinemacula/coding-standards/js/eslint/type-checked';
146
149
  export default [...typeChecked];
147
150
  ```
148
151
 
152
+ Vue repositories add the Vue toolchain and spread the Vue layer after the layer they already use:
153
+
154
+ ```bash
155
+ npm install --save-dev eslint-plugin-vue vue-eslint-parser eslint-plugin-check-file
156
+ ```
157
+
158
+ ```js
159
+ import typeChecked from '@sinemacula/coding-standards/js/eslint/type-checked';
160
+ import vue from '@sinemacula/coding-standards/js/eslint/vue';
161
+
162
+ export default [...typeChecked, ...vue];
163
+ ```
164
+
165
+ The Vue layer registers the single-file-component parser (without it `.vue` files are not linted at all), resolves
166
+ `<script lang="ts">` blocks through the TypeScript parser, and holds component filenames to kebab-case. It also
167
+ carries the template layout rules, which is the one place ESLint takes on formatting: Biome does not understand
168
+ single-file components, so `.vue` markup would otherwise go unformatted entirely. Those rules are aligned to the
169
+ shared four-space indent.
170
+
149
171
  When wiring ESLint through Qlty, the shared eslint plugin sandbox installs only `eslint`, `jest`, and `prettier` by
150
172
  default, so the flat config's imports of this package and `typescript-eslint` fail to resolve. Widen the install
151
173
  filter in your `.qlty/qlty.toml` so the sandbox carries them (this repository's `source.toml` exports the same
@@ -156,6 +178,17 @@ override, but source-exported plugin definitions do not reliably propagate, so m
156
178
  package_filters = ["@sinemacula/coding-standards", "typescript-eslint", "@typescript-eslint", "eslint-plugin-jsdoc"]
157
179
  ```
158
180
 
181
+ Repositories enabling the Vue layer widen the same filter further, since its plugins have to resolve inside that
182
+ sandbox too:
183
+
184
+ ```toml
185
+ [plugins.definitions.eslint]
186
+ package_filters = [
187
+ "@sinemacula/coding-standards", "typescript-eslint", "@typescript-eslint", "eslint-plugin-jsdoc",
188
+ "eslint-plugin-vue", "vue-eslint-parser", "eslint-plugin-check-file",
189
+ ]
190
+ ```
191
+
159
192
  ### Knip (JavaScript / TypeScript)
160
193
 
161
194
  ```json
@@ -187,7 +220,7 @@ tag = "<version>"
187
220
  | `php/phpstan-base.neon` | PHPStan | Base config (org-wide ignored errors + settings) |
188
221
  | `js/biome.json` | Biome | JavaScript / TypeScript formatter + linter rules |
189
222
  | `js/knip.json` | Knip | Unused-export detection rules |
190
- | `js/eslint/` | ESLint | Custom structural + type-aware rules; runs with Biome |
223
+ | `js/eslint/` | ESLint | Structural, type-aware + Vue rules; runs with Biome |
191
224
  | `markdown/.markdownlint.json` | markdownlint | Markdown linting rules |
192
225
  | `yaml/.yamllint.yaml` | yamllint | YAML linting rules |
193
226
  | `shell/.shellcheckrc` | ShellCheck | Shell script linting rules |
@@ -249,14 +282,17 @@ type-checked layer.
249
282
  | `@sinemacula/max-methods-per-class` | A single class may declare at most 20 methods; test code exempt. |
250
283
  | `@sinemacula/no-base-error` | Throw a domain-specific `Error` subclass, never the base `Error`; test code exempt. |
251
284
  | `@sinemacula/require-copyright` | Every file must carry a documentation comment with `@copyright` and `@author`. |
285
+ | `@sinemacula/align-doc-tags` | `@author` and `@copyright` values line up at a single column; autofixable. |
252
286
 
253
287
  `boolean-method-name` takes `additionalPrefixes`, `additionalPredicates` and `additionalCommandVerbs` (string arrays)
254
288
  to widen the accepted vocabulary from a consumer config. `max-methods-per-class` takes `max`, `no-base-error` takes
255
- `allow`, and `require-copyright` takes `tags` to adjust their defaults.
289
+ `allow`, and `require-copyright` takes `tags` to adjust their defaults. `align-doc-tags` takes `tags` and `column`,
290
+ the column counting from the `@`, so the default of 14 gives `@author` six spaces and `@copyright` three.
256
291
 
257
292
  The base layer also switches on a set of built-in rules: `@typescript-eslint/no-explicit-any`, `max-lines-per-function`
258
293
  (50 lines, test code exempt) and `max-depth` (4), plus `eslint-plugin-jsdoc` rules that require a documentation comment
259
- on every declared function, method and class and forbid types in `@param`/`@returns` (types belong in the signature).
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.
260
296
  The type-checked layer adds `@typescript-eslint/explicit-module-boundary-types` and
261
297
  `@typescript-eslint/only-throw-error`.
262
298
 
package/js/biome.json CHANGED
@@ -60,6 +60,12 @@
60
60
  "useConst": "warn",
61
61
  "useDefaultParameterLast": "warn",
62
62
  "useExponentiationOperator": "warn",
63
+ "useFilenamingConvention": {
64
+ "level": "warn",
65
+ "options": {
66
+ "filenameCases": ["kebab-case"]
67
+ }
68
+ },
63
69
  "useNamingConvention": {
64
70
  "level": "warn",
65
71
  "options": {
@@ -98,7 +104,16 @@
98
104
  "enabled": true,
99
105
  "actions": {
100
106
  "source": {
101
- "organizeImports": "on"
107
+ "organizeImports": {
108
+ "level": "on",
109
+ "options": {
110
+ "groups": [
111
+ [":BUN:", ":NODE:", ":PACKAGE_WITH_PROTOCOL:", ":URL:", ":PACKAGE:"],
112
+ ":BLANK_LINE:",
113
+ [":ALIAS:", ":PATH:"]
114
+ ]
115
+ }
116
+ }
102
117
  }
103
118
  }
104
119
  },
@@ -8,11 +8,11 @@ const TS_AND_JS_FILES = [...TS_FILES, '**/*.js', '**/*.jsx', '**/*.mjs', '**/*.c
8
8
  /**
9
9
  * Base flat config: the AST-only custom rules that need no type information.
10
10
  *
11
- * Requires no tsconfig, so it stays cheap. The typescript-eslint parser resolves
12
- * TypeScript syntax. The interface, readonly-property and enum rules target
13
- * TypeScript-only constructs; no-mutable-static also applies to plain JavaScript
14
- * (exported let/var, mutable static fields), so it runs across both. The opt-in
15
- * type-aware layer lives in ./type-checked.js.
11
+ * Requires no tsconfig, so it stays cheap. The typescript-eslint parser
12
+ * resolves TypeScript syntax. The interface, readonly-property and enum rules
13
+ * target TypeScript-only constructs; no-mutable-static also applies to plain
14
+ * JavaScript (exported let/var, mutable static fields), so it runs across both.
15
+ * The opt-in type-aware layer lives in ./type-checked.js.
16
16
  *
17
17
  * @author Ben Carey <bdmc@sinemacula.co.uk>
18
18
  * @copyright 2026 Sine Macula Limited
@@ -49,13 +49,17 @@ 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',
52
53
 
53
54
  'max-lines-per-function': ['error', { max: 50, skipComments: true, skipBlankLines: true, IIFEs: true }],
54
55
  'max-depth': ['error', 4],
55
56
 
56
- // Every declared function, method, class and assigned arrow carries a
57
- // documentation comment describing intent; types live in the signature,
58
- // never in the comment (no @param/@returns type tags).
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
63
  'jsdoc/require-jsdoc': ['error', {
60
64
  require: {
61
65
  ClassDeclaration: true,
@@ -73,6 +77,7 @@ export default [
73
77
  'jsdoc/no-types': 'error',
74
78
  'jsdoc/require-param-description': 'error',
75
79
  'jsdoc/require-returns-description': 'error',
80
+ 'jsdoc/lines-before-block': ['error', { lines: 1, ignoreSingleLines: false }],
76
81
  },
77
82
  },
78
83
  {
@@ -1,3 +1,4 @@
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';
3
4
  import noBaseError from './rules/no-base-error.js';
@@ -30,5 +31,6 @@ export default {
30
31
  'max-methods-per-class': maxMethodsPerClass,
31
32
  'no-base-error': noBaseError,
32
33
  'require-copyright': requireCopyright,
34
+ 'align-doc-tags': alignDocTags,
33
35
  },
34
36
  };
@@ -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
+ });
@@ -52,8 +52,9 @@ function isCommandVerb(name, verbs) {
52
52
 
53
53
  /**
54
54
  * Whether a return type resolves to boolean, ignoring a nullable `?bool`-style
55
- * null/undefined/void tail so an optional boolean still counts. Promise wrappers
56
- * are unwrapped before this is called, so an awaited boolean counts too.
55
+ * null/undefined/void tail so an optional boolean still counts. Promise
56
+ * wrappers are unwrapped before this is called, so an awaited boolean counts
57
+ * too.
57
58
  */
58
59
  function returnsBoolean(type) {
59
60
  if (type.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) {
@@ -71,7 +72,9 @@ function returnsBoolean(type) {
71
72
  return false;
72
73
  }
73
74
 
74
- /** Whether the node is a function expression carrying an inspectable signature. */
75
+ /**
76
+ * Whether the node is a function expression carrying an inspectable signature.
77
+ */
75
78
  function isFunctionExpression(node) {
76
79
  return node != null
77
80
  && (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression');
@@ -90,7 +93,9 @@ function keyName(keyNode) {
90
93
  return null;
91
94
  }
92
95
 
93
- /** Peel `as`/`satisfies`/non-null wrappers off an expression to reach the value. */
96
+ /**
97
+ * Peel `as`/`satisfies`/non-null wrappers off an expression to reach the value.
98
+ */
94
99
  function unwrapExpression(node) {
95
100
  let current = node;
96
101
 
@@ -128,8 +133,8 @@ function hasImperativeTag(sourceCode, docHost, nameNode) {
128
133
 
129
134
  /**
130
135
  * Report the name when it neither reads as a predicate nor is exempt and the
131
- * resolved (awaited) return type is boolean. Type-predicate guards are predicates
132
- * by structure and left alone.
136
+ * resolved (awaited) return type is boolean. Type-predicate guards are
137
+ * predicates by structure and left alone.
133
138
  */
134
139
  function inspect(state, nameNode, name, fnNode, docHost) {
135
140
  const { checker, services, context, sourceCode } = state;
@@ -195,8 +200,9 @@ function inspectMember(state, node, allowEmptyBody) {
195
200
  }
196
201
 
197
202
  /**
198
- * The visitor: inspect each named function, method, signature and function-valued
199
- * member for a boolean return that does not read as a predicate.
203
+ * The visitor: inspect each named function, method, signature and
204
+ * function-valued member for a boolean return that does not read as a
205
+ * predicate.
200
206
  */
201
207
  function buildListeners(state) {
202
208
  return {
@@ -253,17 +259,19 @@ function buildListeners(state) {
253
259
  * Boolean method name rule.
254
260
  *
255
261
  * A function, method, arrow-bound class field, object method or interface/type
256
- * signature returning boolean should read as a predicate. A name is accepted when
257
- * its first camelCase word is a copular or modal prefix (is, has, can, ...), an
258
- * idiomatic predicate from ALLOWED_PREDICATES (e.g. successful), or a verb ending
259
- * in `s` (third-person: permits, passes) or `ed` (past tense: succeeded, failed,
260
- * expired). An imperative command verb (execute, persist, guard, ...) that returns
261
- * a result bool is exempt via COMMAND_VERBS. A member may also opt out with an
262
+ * signature returning boolean should read as a predicate. A name is accepted
263
+ * when its first camelCase word is a copular or modal prefix (is, has, can,
264
+ * ...), an idiomatic predicate from ALLOWED_PREDICATES (e.g. successful), or a
265
+ * verb ending in `s` (third-person: permits, passes) or `ed` (past tense:
266
+ * succeeded, failed, expired). An imperative command verb (execute, persist,
267
+ * guard, ...) that returns a result bool is exempt via COMMAND_VERBS. A member
268
+ * may also opt out with an
262
269
  * @imperative docblock tag. Accessors, the constructor, computed names, magic
263
- * names and type-predicate guards (x is T) are exempt. The return type is resolved
264
- * from type information - inferred booleans and awaited Promise<boolean> included -
265
- * so the rule degrades to a no-op when no type information is available. The
266
- * accepted vocabulary can be widened per consumer through the rule options.
270
+ * names and type-predicate guards (x is T) are exempt. The return type is
271
+ * resolved from type information - inferred booleans and awaited
272
+ * Promise<boolean> included - so the rule degrades to a no-op when no type
273
+ * information is available. The accepted vocabulary can be widened per consumer
274
+ * through the rule options.
267
275
  *
268
276
  * @author Ben Carey <bdmc@sinemacula.co.uk>
269
277
  * @copyright 2026 Sine Macula Limited
@@ -293,13 +301,14 @@ export default createRule({
293
301
  const services = ESLintUtils.getParserServices(context, true);
294
302
 
295
303
  // Without a type-checker program the return type can't be resolved, so
296
- // the rule cannot decide anything; degrade to a no-op rather than throw.
304
+ // the rule cannot decide anything; degrade to a no-op rather than
305
+ // throw.
297
306
  if (!services.program) {
298
307
  return {};
299
308
  }
300
309
 
301
- // Merge consumer additions onto the defaults so a downstream ruleset can
302
- // widen the accepted vocabulary without losing the built-in words.
310
+ // Merge consumer additions onto the defaults so a downstream ruleset
311
+ // can widen the accepted vocabulary without losing the built-in words.
303
312
  const state = {
304
313
  context,
305
314
  services,
@@ -12,7 +12,9 @@ export const createRule = ESLintUtils.RuleCreator(
12
12
  name => `https://github.com/sinemacula/coding-standards#${name}`,
13
13
  );
14
14
 
15
- /** Whether the file is a TypeScript declaration file (.d.ts, .d.mts, .d.cts). */
15
+ /**
16
+ * Whether the file is a TypeScript declaration file (.d.ts, .d.mts, .d.cts).
17
+ */
16
18
  export function isDeclarationFile(filename) {
17
19
  return /\.d\.[cm]?ts$/.test(filename);
18
20
  }
@@ -78,7 +80,9 @@ export function superClassName(klass) {
78
80
  return null;
79
81
  }
80
82
 
81
- /** Whether the class reads as a test class (by its own or its parent's name). */
83
+ /**
84
+ * Whether the class reads as a test class (by its own or its parent's name).
85
+ */
82
86
  export function isTestClass(klass) {
83
87
  if (klass.id?.name?.endsWith('Test')) {
84
88
  return true;
@@ -2,8 +2,8 @@ import { createRule, isTestClass, isTestPath } from './lib.js';
2
2
 
3
3
  /** Whether a class member counts towards the method total. */
4
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.
5
+ // An overload signature shares its name with the implementation below it,
6
+ // so it carries no body and is not counted a second time.
7
7
  return member.type === 'MethodDefinition'
8
8
  && member.value.type !== 'TSEmptyBodyFunctionExpression';
9
9
  }
@@ -28,8 +28,8 @@ function countMethods(node) {
28
28
  * Every method declared on the class body counts, including the constructor,
29
29
  * static methods and get/set accessors; a method spread across overload
30
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.
31
+ * belong to that class, not the one enclosing it. Test code legitimately
32
+ * gathers many methods on one fixture, so a test file or test class is exempt.
33
33
  *
34
34
  * @author Ben Carey <bdmc@sinemacula.co.uk>
35
35
  * @copyright 2026 Sine Macula Limited
@@ -3,7 +3,10 @@ import { createRule, isTestPath } from './lib.js';
3
3
  /** Global objects whose `Error` member resolves to the base `Error`. */
4
4
  const GLOBAL_OBJECTS = new Set(['globalThis', 'window', 'global', 'self']);
5
5
 
6
- /** Strips the `as`, `satisfies` and non-null wrappers a throw argument may carry. */
6
+ /**
7
+ * Strips the `as`, `satisfies` and non-null wrappers a throw argument may
8
+ * carry.
9
+ */
7
10
  function unwrapType(node) {
8
11
  let current = node;
9
12
 
@@ -36,7 +39,10 @@ function isGlobalError(callee) {
36
39
  && isErrorProperty(callee.property);
37
40
  }
38
41
 
39
- /** Whether a construction callee denotes the base `Error`, directly or through a global object. */
42
+ /**
43
+ * Whether a construction callee denotes the base `Error`, directly or through a
44
+ * global object.
45
+ */
40
46
  function isBaseError(callee) {
41
47
  if (callee.type === 'Identifier') {
42
48
  return callee.name === 'Error';
@@ -55,11 +61,11 @@ function isBaseError(callee) {
55
61
  * The check is syntactic. It flags a throw whose argument constructs the base
56
62
  * `Error`, whether named directly (`new Error()`) or reached through a global
57
63
  * 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
64
+ * `throw ...!` or `satisfies` annotation is unwrapped before the construction
65
+ * is inspected, so the annotation cannot hide the throw. Subclasses
66
+ * (`new NotFoundError()`) and the specific built-ins (`new TypeError()`) read
67
+ * as domain-specific and pass; a re-thrown variable (`throw err`), a qualified
68
+ * name (`new foo.Error()`) and a base `Error` built for a non-throw use
63
69
  * (`const e = new Error()`) fall outside the pattern. Only the base `Error` is
64
70
  * ever a candidate, so listing `Error` in the `allow` option is the one way to
65
71
  * permit it; any other name has nothing to match and stays inert.
@@ -1,6 +1,9 @@
1
1
  import { createRule } from './lib.js';
2
2
 
3
- /** Disallowed "I" prefix: a capital I directly followed by another uppercase letter. */
3
+ /**
4
+ * Disallowed "I" prefix: a capital I directly followed by another uppercase
5
+ * letter.
6
+ */
4
7
  const PREFIX_PATTERN = /^I[A-Z]/;
5
8
 
6
9
  /** A global or string-named module block augments types we do not own. */
@@ -31,7 +34,9 @@ export default createRule({
31
34
  create(context) {
32
35
  const { sourceCode } = context;
33
36
 
34
- /** Report a declaration whose name carries the disallowed "I" prefix. */
37
+ /**
38
+ * Report a declaration whose name carries the disallowed "I" prefix.
39
+ */
35
40
  const check = (node, kind) => {
36
41
  const name = node.id.name;
37
42
 
@@ -39,7 +44,8 @@ export default createRule({
39
44
  return;
40
45
  }
41
46
 
42
- // Augmenting an external module or the global scope cannot rename it.
47
+ // Augmenting an external module or the global scope cannot rename
48
+ // it.
43
49
  if (sourceCode.getAncestors(node).some(isExternalAugmentation)) {
44
50
  return;
45
51
  }
@@ -1,12 +1,16 @@
1
1
  import { ASTUtils } from '@typescript-eslint/utils';
2
2
  import { createRule, isAmbient, isDeclarationFile, isTestClass, isTestPath, nearestClass } from './lib.js';
3
3
 
4
- /** Unwrap a rest element to its bound target, else return the node unchanged. */
4
+ /**
5
+ * Unwrap a rest element to its bound target, else return the node unchanged.
6
+ */
5
7
  function restTarget(node) {
6
8
  return node.type === 'RestElement' ? node.argument : node;
7
9
  }
8
10
 
9
- /** The destructuring children of a pattern that may bind further identifiers. */
11
+ /**
12
+ * The destructuring children of a pattern that may bind further identifiers.
13
+ */
10
14
  function patternChildren(pattern) {
11
15
  switch (pattern.type) {
12
16
  case 'ArrayPattern':
@@ -22,7 +26,10 @@ function patternChildren(pattern) {
22
26
  }
23
27
  }
24
28
 
25
- /** Collect the bound identifiers of a declarator target, unwrapping destructuring. */
29
+ /**
30
+ * Collect the bound identifiers of a declarator target, unwrapping
31
+ * destructuring.
32
+ */
26
33
  function boundIdentifiers(pattern, out) {
27
34
  if (pattern.type === 'Identifier') {
28
35
  out.push(pattern);
@@ -36,7 +43,8 @@ function boundIdentifiers(pattern, out) {
36
43
 
37
44
  /**
38
45
  * 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.
46
+ * function, import or an ambient declaration), and so publishes live module
47
+ * state.
40
48
  */
41
49
  function bindsMutableVariable(variable, filename) {
42
50
  if (isDeclarationFile(filename)) {
@@ -48,7 +56,9 @@ function bindsMutableVariable(variable, filename) {
48
56
  );
49
57
  }
50
58
 
51
- /** Matches @managed-static only at a docblock tag position, never inside prose. */
59
+ /**
60
+ * Matches @managed-static only at a docblock tag position, never inside prose.
61
+ */
52
62
  const MANAGED_TAG = /(?:^|[\s*])@managed-static(?![-\w])/i;
53
63
 
54
64
  /**
@@ -62,8 +72,8 @@ function hasManagedTag(node, sourceCode) {
62
72
  return true;
63
73
  }
64
74
 
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.
75
+ // A docblock tucked between the decorators and the declaration sits before
76
+ // the first token after the last decorator, not before the whole member.
67
77
  if (node.decorators?.length) {
68
78
  const afterDecorators = sourceCode.getTokenAfter(node.decorators.at(-1));
69
79
  const inner = afterDecorators && sourceCode.getCommentsBefore(afterDecorators).at(-1);
@@ -74,7 +84,9 @@ function hasManagedTag(node, sourceCode) {
74
84
  return false;
75
85
  }
76
86
 
77
- /** A readable name for a class member key, including private and computed forms. */
87
+ /**
88
+ * A readable name for a class member key, including private and computed forms.
89
+ */
78
90
  function describeKey(node, sourceCode) {
79
91
  if (node.computed) {
80
92
  return `[${sourceCode.getText(node.key)}]`;
@@ -91,7 +103,10 @@ function describeKey(node, sourceCode) {
91
103
  return node.key.name;
92
104
  }
93
105
 
94
- /** Report each identifier bound by an inline `export let`/`export var` declaration. */
106
+ /**
107
+ * Report each identifier bound by an inline `export let`/`export var`
108
+ * declaration.
109
+ */
95
110
  function reportInlineExports(node, context) {
96
111
  const declaration = node.declaration;
97
112
 
@@ -116,7 +131,8 @@ function reportInlineExports(node, context) {
116
131
 
117
132
  /** Report `export { x }` specifiers that publish a mutable local binding. */
118
133
  function reportSpecifierExports(node, context) {
119
- // A re-export carries no local binding; a type-only export carries no runtime one.
134
+ // A re-export carries no local binding; a type-only export carries no
135
+ // runtime one.
120
136
  if (node.source || node.exportKind === 'type') {
121
137
  return;
122
138
  }
@@ -137,18 +153,18 @@ function reportSpecifierExports(node, context) {
137
153
  }
138
154
 
139
155
  /**
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.
156
+ * Forbids mutable module-level and class-level static state: exported
157
+ * `let`/`var` bindings (declared inline or published through an
158
+ * `export { ... }` specifier) and non-readonly `static` class fields, including
159
+ * `static accessor` auto-accessors. All are global mutable state; `const` and
160
+ * `readonly` express the read-only configuration this allows.
145
161
  *
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.
162
+ * The check is syntactic, not write-sensitive: a static is flagged whether or
163
+ * not a reassignment is visible, since cross-file writes are out of a per-file
164
+ * rule's reach. Deliberately mutated statics opt out with a `@managed-static`
165
+ * doc tag on the field or its declaring class, and test classes are exempt.
166
+ * Module scope is limited to exported bindings; an unexported module `let`
167
+ * stays local and is left alone.
152
168
  *
153
169
  * @author Ben Carey <bdmc@sinemacula.co.uk>
154
170
  * @copyright 2026 Sine Macula Limited
@@ -170,7 +186,10 @@ export default createRule({
170
186
  create(context) {
171
187
  const { sourceCode } = context;
172
188
 
173
- /** Whether a static member is exempt: test code or an opted-out declaration. */
189
+ /**
190
+ * Whether a static member is exempt: test code or an opted-out
191
+ * declaration.
192
+ */
174
193
  const isStaticExempt = node => {
175
194
  if (isTestPath(context.filename)) {
176
195
  return true;
@@ -185,7 +204,10 @@ export default createRule({
185
204
  return hasManagedTag(node, sourceCode) || (klass !== null && hasManagedTag(klass, sourceCode));
186
205
  };
187
206
 
188
- /** Flag a non-readonly static field or auto-accessor as mutable static state. */
207
+ /**
208
+ * Flag a non-readonly static field or auto-accessor as mutable static
209
+ * state.
210
+ */
189
211
  const inspectStatic = node => {
190
212
  if (!node.static || node.readonly || isAmbient(node, context.filename) || isStaticExempt(node)) {
191
213
  return;
@@ -82,7 +82,8 @@ export default createRule({
82
82
  });
83
83
  },
84
84
  'AccessorProperty, TSAbstractAccessorProperty'(node) {
85
- // An auto-accessor cannot be readonly, so a public one is always mutable.
85
+ // An auto-accessor cannot be readonly, so a public one is
86
+ // always mutable.
86
87
  if (isOutOfScope(node) || isExempt(node)) {
87
88
  return;
88
89
  }
@@ -115,7 +116,10 @@ function isNonPublic(node) {
115
116
  || node.accessibility === 'protected';
116
117
  }
117
118
 
118
- /** Whether a class property is outside the public-mutable scope: static, ambient, or non-public. */
119
+ /**
120
+ * Whether a class property is outside the public-mutable scope: static,
121
+ * ambient, or non-public.
122
+ */
119
123
  function isOutOfScope(node) {
120
124
  return node.static || node.declare || isNonPublic(node);
121
125
  }
@@ -32,9 +32,9 @@ export default createRule({
32
32
  return {
33
33
  TSEnumMember(node) {
34
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.
35
+ // A member name is either a bare identifier or a string
36
+ // literal; a string literal is checked by its resolved value,
37
+ // not the raw source text.
38
38
  const name = id.type === 'Identifier' ? id.name : String(id.value);
39
39
 
40
40
  if (!NAME_PATTERN.test(name)) {
@@ -0,0 +1,46 @@
1
+ import checkFile from 'eslint-plugin-check-file';
2
+ import pluginVue from 'eslint-plugin-vue';
3
+ import tseslint from 'typescript-eslint';
4
+
5
+ /**
6
+ * Opt-in Vue layer: the single-file-component rules. Registers the SFC parser
7
+ * so `.vue` files are linted at all, resolves `<script lang="ts">` blocks
8
+ * through the TypeScript parser, and holds component filenames to the same
9
+ * kebab-case convention the shared formatter enforces on plain sources.
10
+ *
11
+ * This layer also carries the template layout rules, which the shared
12
+ * formatter cannot: it does not understand single-file components, so `.vue`
13
+ * markup would otherwise go unformatted entirely.
14
+ *
15
+ * Additive, and deliberately unlike the type-aware layer: it carries no base
16
+ * rules of its own, so spread it after whichever layer a repo already uses
17
+ * rather than in place of one. Vue is orthogonal to type-awareness, and a
18
+ * repo enabling both would otherwise apply the base rules twice.
19
+ *
20
+ * @author Ben Carey <bdmc@sinemacula.co.uk>
21
+ * @copyright 2026 Sine Macula Limited
22
+ */
23
+ export default [
24
+ ...pluginVue.configs['flat/recommended-error'],
25
+ {
26
+ files: ['**/*.vue'],
27
+ plugins: {
28
+ 'check-file': checkFile,
29
+ },
30
+ languageOptions: {
31
+ parserOptions: {
32
+ parser: tseslint.parser,
33
+ },
34
+ },
35
+ rules: {
36
+ // The template rules indent by two; the shared formatter is four.
37
+ 'vue/html-indent': ['error', 4],
38
+
39
+ 'check-file/filename-naming-convention': ['error', {
40
+ '**/*.vue': 'KEBAB_CASE',
41
+ }, {
42
+ ignoreMiddleExtensions: true,
43
+ }],
44
+ },
45
+ },
46
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sinemacula/coding-standards",
3
- "version": "1.9.2",
3
+ "version": "1.11.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>",
@@ -35,6 +35,7 @@
35
35
  "exports": {
36
36
  "./js/eslint": "./js/eslint/index.js",
37
37
  "./js/eslint/type-checked": "./js/eslint/type-checked.js",
38
+ "./js/eslint/vue": "./js/eslint/vue.js",
38
39
  "./*": "./*"
39
40
  },
40
41
  "scripts": {
@@ -45,29 +46,44 @@
45
46
  "@typescript-eslint/rule-tester": "^8.0.0",
46
47
  "@typescript-eslint/utils": "^8.0.0",
47
48
  "eslint": "^9.0.0",
49
+ "eslint-plugin-check-file": "^3.0.0",
48
50
  "eslint-plugin-jsdoc": "^63.0.13",
51
+ "eslint-plugin-vue": "^10.0.0",
49
52
  "typescript": "^5.0.0",
50
53
  "typescript-eslint": "^8.0.0",
51
- "vitest": "^3.0.0"
54
+ "vitest": "^3.0.0",
55
+ "vue-eslint-parser": "^10.0.0"
52
56
  },
53
57
  "peerDependencies": {
54
58
  "eslint": ">=9",
59
+ "eslint-plugin-check-file": ">=3",
55
60
  "eslint-plugin-jsdoc": ">=48",
61
+ "eslint-plugin-vue": ">=10",
56
62
  "typescript": ">=4.8.4",
57
- "typescript-eslint": "^8"
63
+ "typescript-eslint": "^8",
64
+ "vue-eslint-parser": ">=10"
58
65
  },
59
66
  "peerDependenciesMeta": {
60
67
  "eslint": {
61
68
  "optional": true
62
69
  },
70
+ "eslint-plugin-check-file": {
71
+ "optional": true
72
+ },
63
73
  "eslint-plugin-jsdoc": {
64
74
  "optional": true
65
75
  },
76
+ "eslint-plugin-vue": {
77
+ "optional": true
78
+ },
66
79
  "typescript": {
67
80
  "optional": true
68
81
  },
69
82
  "typescript-eslint": {
70
83
  "optional": true
84
+ },
85
+ "vue-eslint-parser": {
86
+ "optional": true
71
87
  }
72
88
  },
73
89
  "publishConfig": {