@sinemacula/coding-standards 1.21.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
@@ -26,8 +26,13 @@ composer require --dev sinemacula/coding-standards
26
26
  npm install --save-dev @sinemacula/coding-standards
27
27
  ```
28
28
 
29
- The npm package ships only the static configs (`js/`, `markdown/`, `yaml/`, `shell/`, `security/`). The PHP autoloaded
30
- code lives in the Composer package.
29
+ The npm package ships only the static configs (`js/`, `markdown/`, `yaml/`, `shell/`, `security/`, `swift/`). The PHP
30
+ autoloaded code lives in the Composer package.
31
+
32
+ ### Qlty (Swift-side: SwiftLint, SwiftFormat)
33
+
34
+ Swift consumers do not need the Composer or npm package. Qlty fetches the shared Swift configs from this repository as
35
+ a pinned source and installs the native tools on macOS.
31
36
 
32
37
  ## Usage
33
38
 
@@ -74,16 +79,19 @@ The `SineMacula` coding standard is auto-discovered via the `phpcodesniffer-stan
74
79
  ### PHPStan
75
80
 
76
81
  The shared PHPStan configs are auto-included via the `extra.phpstan.includes` section in `composer.json`. Your project's
77
- `phpstan.neon` only needs project-specific settings:
82
+ `phpstan.neon` only needs its paths:
78
83
 
79
84
  ```neon
80
85
  parameters:
81
- level: 8
82
86
  paths:
83
87
  - src
84
88
  - tests
85
89
  ```
86
90
 
91
+ Do not set `level`. Analysis runs through qlty, whose phpstan driver passes `--level=9` on the command line, and a
92
+ command-line level overrides the config file outright - so a level set here does nothing except mislead whoever reads
93
+ it next.
94
+
87
95
  The base config enables PHPStan's checked-exception analysis: every exception a method can throw must appear in its
88
96
  `@throws` tag, except a configured set of programming-error and infrastructure exceptions that stay unchecked - the
89
97
  `LogicException`, `RuntimeException` and `Error` families among them (see `php/phpstan-base.neon` for the full list).
@@ -265,6 +273,70 @@ repository = "https://github.com/sinemacula/coding-standards"
265
273
  tag = "<version>"
266
274
  ```
267
275
 
276
+ ### Swift (SwiftLint and SwiftFormat)
277
+
278
+ Swift repositories consume the shared policy through Qlty. Enable the default source for the tools, this repository
279
+ for the exported configs, and both native plugins:
280
+
281
+ ```toml
282
+ config_version = "0"
283
+
284
+ # SwiftLint's own `excluded:` list only applies when it walks a directory. Qlty
285
+ # passes explicit file paths, so generated and third-party sources have to be
286
+ # excluded here or the shared policy is reported against machine-written code.
287
+ exclude_patterns = [
288
+ ".build/**",
289
+ "build/**",
290
+ "DerivedData/**",
291
+ "Carthage/**",
292
+ "Pods/**",
293
+ "vendor/**",
294
+ "**/Generated/**",
295
+ ]
296
+
297
+ test_patterns = [
298
+ "**/*Tests.swift",
299
+ "**/Tests/**",
300
+ ]
301
+
302
+ [[source]]
303
+ name = "default"
304
+ default = true
305
+
306
+ [[source]]
307
+ name = "sinemacula"
308
+ repository = "https://github.com/sinemacula/coding-standards"
309
+ tag = "<version>"
310
+
311
+ [[plugin]]
312
+ name = "swiftlint"
313
+
314
+ [[plugin]]
315
+ name = "swiftformat"
316
+ mode = "comment"
317
+ ```
318
+
319
+ This is the same configuration the package's own integration test runs, so what is documented here is what CI exercises.
320
+
321
+ Run formatting and linting locally:
322
+
323
+ ```bash
324
+ qlty fmt --all
325
+ qlty check --all
326
+ ```
327
+
328
+ SwiftLint and SwiftFormat are native macOS plugins. Qlty Cloud's Linux workers cannot execute them, so every Swift
329
+ consumer needs a macOS CI job that verifies formatting and runs `qlty check`. Qlty Cloud still provides its built-in
330
+ Swift maintainability, duplication, complexity, security, and coverage capabilities.
331
+
332
+ The shared policy targets Swift 6 and deliberately contains no application-specific include paths or architecture
333
+ rules. Xcode compiler checks such as strict concurrency, warnings-as-errors, platform availability, and test builds
334
+ remain the consuming project's responsibility.
335
+
336
+ SwiftLint has no configuration inheritance. A `.swiftlint.yml` committed to a consuming repository *replaces* the
337
+ shared policy rather than extending it, so adding one to relax a single rule silently discards the whole standard.
338
+ Raise a pull request here instead, or copy the shared file wholesale and edit the copy.
339
+
268
340
  ## What's Included
269
341
 
270
342
  | Path | Tool | Description |
@@ -282,6 +354,8 @@ tag = "<version>"
282
354
  | `shell/.shellcheckrc` | ShellCheck | Shell script linting rules |
283
355
  | `security/.gitleaks.toml` | Gitleaks | Secret-detection ruleset |
284
356
  | `editorconfig/.editorconfig-checker.json` | editorconfig-checker | Disables only the max-line-length check |
357
+ | `swift/.swiftlint.yml` | SwiftLint | Shared Swift 6 lint, safety, concurrency, and metrics |
358
+ | `swift/.swiftformat` | SwiftFormat | Shared deterministic Swift 6 formatting policy |
285
359
 
286
360
  ## Rules
287
361
 
@@ -306,7 +380,7 @@ native directive - `// phpcs:ignore <code>` for a sniff, `@phpstan-ignore <ident
306
380
  | `SineMacula.Commenting.SingleLineMemberComment` | A property, constant or enum-case doc comment sits on one line. |
307
381
  | `SineMacula.Exceptions.DisallowBaseException` | No throwing the base `\Exception`; throw a domain exception. |
308
382
  | `SineMacula.Exceptions.RequireEmptyCatchComment` | An empty catch block must comment its intentional swallow. |
309
- | `SineMacula.Functions.RequireSensitiveParameter` | Secret-named params need `#[\SensitiveParameter]`. |
383
+ | `SineMacula.Functions.RequireSensitiveParameter` | Secret-named params need `#[\SensitiveParameter]`; object types exempt. |
310
384
  | `SineMacula.Metrics.MaxMethodCount` | A class/interface/trait/enum may declare at most 20 methods (tests exempt). |
311
385
  | `SineMacula.Metrics.MethodLength` | A method body may have at most 50 significant lines (tests exempt). |
312
386
  | `SineMacula.Namespaces.RequireConcernsNamespace` | Traits must live under a `Concerns` namespace segment. |
@@ -321,10 +395,12 @@ native directive - `// phpcs:ignore <code>` for a sniff, `@phpstan-ignore <ident
321
395
 
322
396
  ### PHPStan rules
323
397
 
324
- | Identifier | Enforces |
325
- |------------------------------------|----------------------------------------------------------------------|
326
- | `sineMacula.mutableStaticProperty` | Static properties written at runtime; `@managed-static` opts out. |
327
- | `sineMacula.readonlyClass` | A final class with only readonly properties must be `readonly`. |
398
+ | Identifier | Enforces |
399
+ |----------------------------------------|--------------------------------------------------------------------------------|
400
+ | `sineMacula.mutableStaticProperty` | Static properties written at runtime; `@managed-static` opts out. |
401
+ | `sineMacula.readonlyClass` | A final class with only readonly properties must be `readonly`. |
402
+ | `sineMacula.redundantStaticReference` | In a final class, `new static`, `static::` and `instanceof static` are `self`. |
403
+ | `sineMacula.redundantStaticReturnType` | In a final class, a `static` return type or `@return` must be `self`. |
328
404
 
329
405
  ### ESLint rules
330
406
 
@@ -341,19 +417,30 @@ type-checked layer. Every rule is scoped to `.ts`/`.js`; `comment-line-wrap` alo
341
417
  | `@sinemacula/max-methods-per-class` | A single class may declare at most 20 methods; test code exempt. |
342
418
  | `@sinemacula/no-base-error` | Throw a domain-specific `Error` subclass, never the base `Error`; test code exempt. |
343
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. |
344
421
  | `@sinemacula/align-doc-tags` | `@author` and `@copyright` values line up at a single column; autofixable. |
345
422
  | `@sinemacula/single-line-property-doc` | A data member's documentation comment sits on one line; autofixable. |
346
423
  | `@sinemacula/multiline-function-doc` | A method's documentation comment spans multiple lines; autofixable. |
347
424
  | `@sinemacula/comment-line-wrap` | Standalone comment prose wrapped to 80 chars, YAML included; premature wraps too. |
348
425
 
349
- `boolean-method-name` takes `additionalPrefixes`, `additionalPredicates` and `additionalCommandVerbs` (string arrays)
350
- to widen the accepted vocabulary from a consumer config. `max-methods-per-class` takes `max`, `no-base-error` takes
351
- `allow`, and `require-copyright` takes `tags` to adjust their defaults. `align-doc-tags` takes `tags` and `column`, the
352
- column counting from the `@`, so the default of 14 gives `@author` six spaces and `@copyright` three. Together
353
- `single-line-property-doc` and `multiline-function-doc` set a member's comment shape by its kind: data members
354
- (interface property signatures, enum members and data class fields) take one line, while methods, interface method
355
- signatures and class fields holding a function take several. A data comment is never required, only held to one line
356
- 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.
357
444
 
358
445
  `comment-line-wrap` takes `maxLength` (default 80) and is the syntax-only counterpart of the PHP
359
446
  `SineMacula.Commenting.CommentLineLength` sniff. It fills standalone `//` and `#` runs and multi-line docblock prose
@@ -374,16 +461,112 @@ than comment, so a shell comment inside a `run: |` step is never seen, and a com
374
461
 
375
462
  The base layer also switches on a set of built-in rules: `@typescript-eslint/no-explicit-any`, `curly` (a brace on every
376
463
  control statement, as PSR-12 already requires on the PHP side), `max-lines-per-function` (50 lines, test code exempt)
377
- and `max-depth` (4), plus `eslint-plugin-jsdoc` rules that require a documentation comment on every declared function,
378
- method, class, interface member and class field, forbid types in `@param`/`@returns` (the tags themselves are welcome,
379
- types belong in the signature) and keep a blank line above every documentation block, single-line blocks included. The
380
- type-checked layer adds `@typescript-eslint/explicit-module-boundary-types` and
381
- `@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.
476
+
477
+ `jsdoc/no-types`, which forbids a type in `@param`/`@returns`, runs over `.ts`/`.tsx`/`.mts`/`.cts` alone, alongside
478
+ `@typescript-eslint/no-explicit-any`. A TypeScript signature already records the type, so the tag would only repeat it
479
+ and is free to drift; plain JavaScript has no signature to hold one, which makes the tag the only place a type is
480
+ written down, and clearing it there would delete the type rather than move it. The description rules are not scoped
481
+ that way: a tag says what a value means whether or not it also says what the value is, so `@param` and `@returns` need
482
+ a description in both languages.
483
+
484
+ ### Swift policy
485
+
486
+ SwiftLint keeps its default rule set and adds curated opt-in rules with a strong correctness, concurrency, safety,
487
+ performance, or readability signal. The policy intentionally avoids analyzer-only rules, which need an Xcode compiler
488
+ log and cannot run through Qlty's normal lint driver.
489
+
490
+ Notable additions include checks for force-unwrapping, silently discarded throwing tasks, invalid concurrency
491
+ annotations, unsafe optional modelling, empty XCTest methods, unbalanced access control, inefficient collection
492
+ operations, oversized closures, and non-private SwiftUI state. A discarded throwing task is an error because it can
493
+ silently lose an operational failure; most style and maintainability findings retain warning severity.
494
+
495
+ The main review and hard ceilings are:
496
+
497
+ | Metric | Warning | Error |
498
+ |-----------------------|--------:|------:|
499
+ | Line length | 120 | 160 |
500
+ | File length | 500 | 800 |
501
+ | Type body length | 300 | 500 |
502
+ | Function body length | 50 | 80 |
503
+ | Closure body length | 50 | 80 |
504
+ | Cyclomatic complexity | 10 | 20 |
505
+ | Function parameters | 6 | 8 |
506
+
507
+ SwiftFormat owns whitespace, wrapping, imports, and other mechanically correctable layout. Its configuration matches
508
+ SwiftLint on 120-column wrapping, import ordering, and no trailing commas, and it is the only tool of the two that
509
+ enforces four-space indentation.
510
+
511
+ The policy also carries over the documentation opinions the PHP and TypeScript standards enforce:
512
+
513
+ - `file_header` requires a copyright header, as `RequireCopyrightTagSniff` does for PHP classes and `require-copyright`
514
+ does for TypeScript declarations. The pattern asserts only that the tag is present, so the holder, year and format
515
+ stay the consuming project's choice, and SwiftFormat's `--header ignore` leaves an existing header untouched.
516
+ - `missing_docs` requires documentation on `open` and `public` declarations, the Swift equivalent of the PHP docblock
517
+ sniffs and `jsdoc/require-jsdoc`. It is scoped to the public surface deliberately: demanding a comment on every
518
+ internal member would generate noise the other two standards do not.
519
+ - `line_length` holds comments to the limit rather than exempting them. SwiftFormat wraps `//` comments to 120 but
520
+ leaves `///` doc comments alone, so without this an over-long documentation line passes both tools - where PHP and
521
+ TypeScript both wrap comment prose. Rules that can alter ownership,
522
+ control flow, explicit `Sendable` conformance, or public API shape are disabled; those changes require human review.
523
+
524
+ ### Methods that only throw
525
+
526
+ A method that exists solely to refuse - a `__serialize()` that throws so a value holding a secret cannot reach a queue
527
+ payload or a cache entry - returns nothing on any path, and `never` is how to say so:
528
+
529
+ ```php
530
+ /**
531
+ * @throws \LogicException
532
+ *
533
+ * @return never
534
+ */
535
+ public function __serialize(): never
536
+ {
537
+ throw new LogicException('A token must not be serialised.');
538
+ }
539
+ ```
540
+
541
+ `never` is a subtype of every return type, so narrowing to it always satisfies an inherited signature, a magic method's
542
+ expected return included. The one place it does not fit is a method a subclass is meant to return from, because a child
543
+ cannot widen `never` back. Such a method keeps the type it declares and throws anyway, which needs no directive:
544
+
545
+ ```php
546
+ /**
547
+ * @throws \LogicException
548
+ *
549
+ * @return array<int, string>
550
+ */
551
+ public function build(): array
552
+ {
553
+ throw new LogicException('Not implemented.');
554
+ }
555
+ ```
556
+
557
+ Whether a documented return is ever produced is a question of control flow, not of tokens, so no sniff here asks it -
558
+ `Squiz.Commenting.FunctionComment.InvalidNoReturn` decides by looking for a `return` token and so faults exactly the
559
+ guard above. PHPStan's `return.missing` answers it properly: it reports a method that can reach its end without
560
+ returning the type it documents, and stays quiet where every path throws.
561
+
562
+ The one thing still worth knowing is that spelling out the contained type of a documented traversable can drag in
563
+ `mixed`, which the mixed ban faults on its own footing and which has its own directive.
382
564
 
383
565
  ## Requirements
384
566
 
385
567
  - PHP ^8.3 (Composer package)
386
568
  - Node.js (npm package)
569
+ - macOS and Qlty CLI (SwiftLint and SwiftFormat policy)
387
570
 
388
571
  ## Testing
389
572
 
@@ -396,6 +579,7 @@ composer analyse # PHPStan static analysis
396
579
  composer check # static analysis and lint via qlty
397
580
  composer format # format via qlty
398
581
  composer smells # duplication / complexity smells via qlty
582
+ bash scripts/test-swift-policy.sh # exported Swift policy integration test (macOS)
399
583
  ```
400
584
 
401
585
  ## Changelog
@@ -12,11 +12,12 @@ const YAML_FILES = ['**/*.yml', '**/*.yaml'];
12
12
  *
13
13
  * Requires no tsconfig, so it stays cheap. The typescript-eslint parser
14
14
  * resolves TypeScript syntax. The interface, readonly-property and enum rules
15
- * target TypeScript-only constructs; no-mutable-static also applies to plain
16
- * JavaScript (exported let/var, mutable static fields), so it runs across both.
17
- * A final block carries the comment-wrap rule alone over YAML, which otherwise
18
- * bounds nothing about a comment's width. The opt-in type-aware layer lives in
19
- * ./type-checked.js.
15
+ * target TypeScript-only constructs, as does jsdoc/no-types, which presumes a
16
+ * signature to hold the type it strips from the tag; no-mutable-static also
17
+ * applies to plain JavaScript (exported let/var, mutable static fields), so it
18
+ * runs across both. A final block carries the comment-wrap rule alone over
19
+ * YAML, which otherwise bounds nothing about a comment's width. The opt-in
20
+ * type-aware layer lives in ./type-checked.js.
20
21
  *
21
22
  * @author Ben Carey <bdmc@sinemacula.co.uk>
22
23
  * @copyright 2026 Sine Macula Limited
@@ -27,6 +28,7 @@ export default [
27
28
  plugins: {
28
29
  '@sinemacula': plugin,
29
30
  '@typescript-eslint': tseslint.plugin,
31
+ jsdoc,
30
32
  },
31
33
  languageOptions: {
32
34
  parser: tseslint.parser,
@@ -37,6 +39,14 @@ export default [
37
39
  '@sinemacula/valid-enum-member-name': 'error',
38
40
 
39
41
  '@typescript-eslint/no-explicit-any': 'error',
42
+
43
+ // A TypeScript signature already records the type, so a tag that
44
+ // repeats it is a second copy free to drift from the first. Plain
45
+ // JavaScript has no signature to hold one, which is why this rule
46
+ // stops at TypeScript: there the tag is the only place a type is
47
+ // written down, and clearing it would delete the type rather than
48
+ // move it to where the reader already looks.
49
+ 'jsdoc/no-types': 'error',
40
50
  },
41
51
  },
42
52
  {
@@ -53,6 +63,7 @@ export default [
53
63
  '@sinemacula/max-methods-per-class': 'error',
54
64
  '@sinemacula/no-base-error': 'error',
55
65
  '@sinemacula/require-copyright': 'error',
66
+ '@sinemacula/require-file-description': 'error',
56
67
  '@sinemacula/align-doc-tags': 'error',
57
68
  '@sinemacula/single-line-property-doc': 'error',
58
69
  '@sinemacula/multiline-function-doc': 'error',
@@ -66,10 +77,11 @@ export default [
66
77
  // property carries a documentation comment describing intent, so a
67
78
  // reader meets each member's purpose before its type. Interface
68
79
  // members and class fields are held to the same bar as methods.
69
- // Types live in the signature, so a tag never annotates one: the
70
- // @param and @returns tags themselves are welcome, only their type
71
- // forms are not. Each block stands off from the code above it,
72
- // single-line blocks included.
80
+ // Every @param and @returns says what the value means, whether or
81
+ // not the tag also carries a type; the type itself is governed by
82
+ // no-types in the TypeScript block above, which is the only place a
83
+ // signature holds one. Each block stands off from the code above
84
+ // it, single-line blocks included.
73
85
  'jsdoc/require-jsdoc': ['error', {
74
86
  require: {
75
87
  ClassDeclaration: true,
@@ -91,7 +103,6 @@ export default [
91
103
  enableFixer: false,
92
104
  }],
93
105
  'jsdoc/require-description': 'error',
94
- 'jsdoc/no-types': 'error',
95
106
  'jsdoc/require-param-description': 'error',
96
107
  'jsdoc/require-returns-description': 'error',
97
108
  'jsdoc/lines-before-block': ['error', { lines: 1, ignoreSingleLines: false }],
@@ -115,11 +126,19 @@ export default [
115
126
  },
116
127
  },
117
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.
118
138
  files: ['**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}', '**/__tests__/**', '**/tests/**', '**/test-support/**'],
119
139
  rules: {
120
140
  'max-lines-per-function': 'off',
121
141
  'max-depth': 'off',
122
- 'jsdoc/require-jsdoc': 'off',
123
142
  },
124
143
  },
125
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,
@@ -140,22 +140,36 @@ function hasImperativeTag(sourceCode, docHost, nameNode) {
140
140
  return false;
141
141
  }
142
142
 
143
+ /**
144
+ * Whether the name is outside the rule's reach: a magic member, a name that
145
+ * already reads as a predicate, a command or event handler, a well-known type
146
+ * accessor, or a member carrying the @imperative opt-out tag.
147
+ */
148
+ function isExempt(state, nameNode, name, docHost) {
149
+ if (name.startsWith('__') || TYPE_ACCESSOR_NAMES.has(name)) {
150
+ return true;
151
+ }
152
+
153
+ if (isPredicate(name, state.prefixes, state.predicates)) {
154
+ return true;
155
+ }
156
+
157
+ if (isCommandVerb(name, state.commandVerbs) || isEventHandler(name)) {
158
+ return true;
159
+ }
160
+
161
+ return hasImperativeTag(state.sourceCode, docHost, nameNode);
162
+ }
163
+
143
164
  /**
144
165
  * Report the name when it neither reads as a predicate nor is exempt and the
145
166
  * resolved (awaited) return type is boolean. Type-predicate guards are
146
167
  * predicates by structure and left alone.
147
168
  */
148
169
  function inspect(state, nameNode, name, fnNode, docHost) {
149
- const { checker, services, context, sourceCode } = state;
150
-
151
- if (
152
- name.startsWith('__')
153
- || isPredicate(name, state.prefixes, state.predicates)
154
- || isCommandVerb(name, state.commandVerbs)
155
- || isEventHandler(name)
156
- || TYPE_ACCESSOR_NAMES.has(name)
157
- || hasImperativeTag(sourceCode, docHost, nameNode)
158
- ) {
170
+ const { checker, services, context } = state;
171
+
172
+ if (isExempt(state, nameNode, name, docHost)) {
159
173
  return;
160
174
  }
161
175
 
@@ -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.21.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>",
@@ -16,6 +16,9 @@
16
16
  "biome",
17
17
  "knip",
18
18
  "eslint",
19
+ "swift",
20
+ "swiftformat",
21
+ "swiftlint",
19
22
  "typescript",
20
23
  "coding-standards",
21
24
  "linting",
@@ -28,9 +31,11 @@
28
31
  "yaml/",
29
32
  "shell/",
30
33
  "security/",
34
+ "swift/",
31
35
  "README.md",
32
36
  "LICENSE",
33
37
  "NOTICE",
38
+ "!js/eslint/__tests__",
34
39
  "!js/eslint/rules/__tests__"
35
40
  ],
36
41
  "exports": {
@@ -53,7 +58,7 @@
53
58
  "eslint-plugin-vue": "^10.0.0",
54
59
  "typescript": "^5.0.0",
55
60
  "typescript-eslint": "^8.0.0",
56
- "vitest": "^3.0.0",
61
+ "vitest": "^4.1.11",
57
62
  "vue-eslint-parser": "^10.0.0",
58
63
  "yaml-eslint-parser": "^2.1.0"
59
64
  },
@@ -0,0 +1,48 @@
1
+ # Shared Swift 6 formatting policy for Sine Macula applications and packages.
2
+
3
+ --swiftversion 6.0
4
+ --indent 4
5
+ --linebreaks lf
6
+ --maxwidth 120
7
+ --trim-whitespace always
8
+ --xcode-indentation enabled
9
+
10
+ # Keep multiline declarations deterministic and easy to diff.
11
+ --wrap-arguments before-first
12
+ --wrap-parameters before-first
13
+ --wrap-collections before-first
14
+ --wrap-conditions before-first
15
+ --wrap-effects if-multiline
16
+ --wrap-return-type if-multiline
17
+ --closing-paren balanced
18
+ --call-site-paren balanced
19
+
20
+ # Match SwiftLint and avoid formatter/linter churn. SwiftLint's sorted_imports
21
+ # orders imports by module name alone, while SwiftFormat defaults to grouping by
22
+ # access level first (access-control,alpha). On a file using Swift 6 access-level
23
+ # imports that disagreement makes `qlty fmt` emit an order `qlty check` rejects,
24
+ # and re-running the formatter never settles it.
25
+ --import-grouping alpha
26
+ --trailing-commas never
27
+ --semicolons never
28
+ --self remove
29
+ --ifdef no-indent
30
+
31
+ # SwiftLint's file_header rule owns the copyright header. `ignore` leaves an
32
+ # existing header alone rather than rewriting every file to one template, so the
33
+ # holder and format stay the consuming project's choice.
34
+ --header ignore
35
+
36
+ # Do not rewrite public function signatures just because an implementation
37
+ # does not currently use an argument. Removing unused closure arguments is
38
+ # local and cannot alter a declared API.
39
+ --strip-unused-args closure-only
40
+
41
+ # Formatting must not change ownership, control flow, or API shape.
42
+ --anonymous-for-each ignore
43
+ --disable enumNamespaces,initCoderUnavailable,preferForLoop,redundantSendable,strongOutlets
44
+
45
+ # Mirror the exclusions in .swiftlint.yml. SwiftFormat rewrites files in place,
46
+ # and unlike SwiftLint's `excluded:` it honours these even when a path is passed
47
+ # explicitly - so generated and third-party code is protected under Qlty too.
48
+ --exclude .build,build,DerivedData,Carthage,Pods,vendor,**/Generated
@@ -0,0 +1,211 @@
1
+ # Shared Swift 6 lint policy for Sine Macula applications and packages.
2
+ #
3
+ # Consumer-specific include paths do not belong here. Qlty determines the files
4
+ # passed to SwiftLint, while the exclusions below protect direct CLI and Xcode
5
+ # build-phase usage from generated and third-party code.
6
+ #
7
+ # Note that SwiftLint applies `excluded:` only when it walks a directory itself.
8
+ # Qlty passes explicit file paths, so a consumer must also exclude generated and
9
+ # third-party sources via `exclude_patterns` in its own .qlty/qlty.toml.
10
+
11
+ excluded:
12
+ - .build
13
+ - build
14
+ - DerivedData
15
+ - Carthage
16
+ - Pods
17
+ - vendor
18
+ - "**/Generated"
19
+ - "**/Generated/**"
20
+
21
+ reporter: xcode
22
+
23
+ # SwiftLint's default rules remain enabled. These opt-in rules add checks with a
24
+ # strong correctness, concurrency, safety, performance, or readability signal
25
+ # without requiring a particular application architecture.
26
+ opt_in_rules:
27
+ - accessibility_label_for_image
28
+ - accessibility_trait_for_button
29
+ - anonymous_argument_in_multiline_closure
30
+ - array_init
31
+ - closure_body_length
32
+ - closure_spacing
33
+ - collection_alignment
34
+ - contains_over_filter_count
35
+ - contains_over_filter_is_empty
36
+ - contains_over_first_not_nil
37
+ - contains_over_range_nil_comparison
38
+ - convenience_type
39
+ - direct_return
40
+ - discarded_notification_center_observer
41
+ - discouraged_assert
42
+ - discouraged_none_name
43
+ - discouraged_optional_boolean
44
+ - discouraged_optional_collection
45
+ - empty_collection_literal
46
+ - empty_count
47
+ - empty_string
48
+ - empty_xctest_method
49
+ - enum_case_associated_values_count
50
+ - expiring_todo
51
+ - explicit_init
52
+ - fallthrough
53
+ - fatal_error_message
54
+ - final_test_case
55
+ - first_where
56
+ - flatmap_over_map_reduce
57
+ - force_unwrapping
58
+ - function_default_parameter_at_end
59
+ - identical_operands
60
+ - implicitly_unwrapped_optional
61
+ - incompatible_concurrency_annotation
62
+ - joined_default_parameter
63
+ - last_where
64
+ - literal_expression_end_indentation
65
+ - local_doc_comment
66
+ - lower_acl_than_parent
67
+ - file_header
68
+ - missing_docs
69
+ - modifier_order
70
+ - no_empty_block
71
+ - non_overridable_class_declaration
72
+ - optional_enum_case_matching
73
+ - override_in_extension
74
+ - pattern_matching_keywords
75
+ - prefer_condition_list
76
+ - prefer_key_path
77
+ - prefer_self_in_static_references
78
+ - prefer_self_type_over_type_of_self
79
+ - prefer_zero_over_explicit_init
80
+ - private_subject
81
+ - private_swiftui_state
82
+ - reduce_into
83
+ - redundant_nil_coalescing
84
+ - redundant_type_annotation
85
+ - return_value_from_void_function
86
+ - shorthand_argument
87
+ - shorthand_optional_binding
88
+ - sorted_first_last
89
+ - sorted_imports
90
+ - static_operator
91
+ - strict_fileprivate
92
+ - superfluous_else
93
+ - test_case_accessibility
94
+ - toggle_bool
95
+ - unavailable_function
96
+ - unhandled_throwing_task
97
+ - unneeded_parentheses_in_closure_argument
98
+ - unowned_variable_capture
99
+ - untyped_error_in_catch
100
+ - unused_parameter
101
+ - variable_shadowing
102
+ - weak_delegate
103
+ - xct_specific_matcher
104
+ - yoda_condition
105
+
106
+ # Thresholds intentionally distinguish a review signal from a hard ceiling. Qlty
107
+ # preserves the warning/error severity in its findings.
108
+ closure_body_length:
109
+ warning: 50
110
+ error: 80
111
+
112
+ cyclomatic_complexity:
113
+ warning: 10
114
+ error: 20
115
+ ignores_case_statements: false
116
+
117
+ enum_case_associated_values_count:
118
+ warning: 5
119
+ error: 6
120
+
121
+ file_length:
122
+ warning: 500
123
+ error: 800
124
+ ignore_comment_only_lines: true
125
+
126
+ function_body_length:
127
+ warning: 50
128
+ error: 80
129
+
130
+ function_parameter_count:
131
+ warning: 6
132
+ error: 8
133
+ ignores_default_parameters: true
134
+
135
+ identifier_name:
136
+ min_length:
137
+ warning: 3
138
+ error: 2
139
+ max_length:
140
+ warning: 50
141
+ error: 60
142
+ excluded:
143
+ - id
144
+ - x
145
+ - y
146
+
147
+ line_length:
148
+ warning: 120
149
+ error: 160
150
+ ignores_urls: true
151
+ ignores_function_declarations: false
152
+ # SwiftFormat wraps `//` comments to this width but leaves `///` doc comments
153
+ # alone, so exempting comments here would let an over-long documentation line
154
+ # pass both tools. PHP and TypeScript hold comments to the limit too.
155
+ ignores_comments: false
156
+ ignores_interpolated_strings: true
157
+ ignores_multiline_strings: true
158
+ ignores_regex_literals: true
159
+
160
+ nesting:
161
+ type_level:
162
+ warning: 2
163
+ function_level:
164
+ warning: 3
165
+ check_nesting_in_closures_and_statements: true
166
+ always_allow_one_type_in_functions: false
167
+ ignore_typealiases_and_associatedtypes: true
168
+ ignore_coding_keys: true
169
+
170
+ type_body_length:
171
+ warning: 300
172
+ error: 500
173
+ excluded_types:
174
+ - extension
175
+ - protocol
176
+
177
+ type_name:
178
+ min_length:
179
+ warning: 3
180
+ error: 2
181
+ max_length:
182
+ warning: 50
183
+ error: 60
184
+
185
+ # Match SwiftFormat's explicit no-trailing-comma policy.
186
+ trailing_comma:
187
+ mandatory_comma: false
188
+
189
+ # The PHP standard requires an @copyright tag in every class docblock and the
190
+ # TypeScript standard requires @copyright and @author, so a Swift file carries a
191
+ # copyright header too. The pattern deliberately asserts only that the tag is
192
+ # present: the holder, year and format stay the consuming project's to choose,
193
+ # and SwiftFormat's `--header ignore` leaves an existing header untouched rather
194
+ # than rewriting it to a template.
195
+ file_header:
196
+ required_pattern: '(?:\/\/|\/\*)(?s:.)*[Cc]opyright(?s:.)*'
197
+
198
+ # PHP requires a docblock on every class and method, TypeScript requires a JSDoc
199
+ # block with a description. Swift's equivalent is held to the public surface:
200
+ # `open` and `public` declarations are the API a consumer reads, and demanding a
201
+ # comment on every internal member would be noise the other two standards do not
202
+ # generate either.
203
+ missing_docs:
204
+ warning: [open, public]
205
+ excludes_extensions: true
206
+ excludes_inherited_types: true
207
+ excludes_trivial_init: true
208
+
209
+ # A discarded throwing task can silently lose an operational failure.
210
+ unhandled_throwing_task:
211
+ severity: error
@@ -0,0 +1,35 @@
1
+ # Swift standards
2
+
3
+ The shared Swift 6 policy consists of:
4
+
5
+ - `.swiftlint.yml` for correctness, concurrency, safety, performance, metrics, and style findings.
6
+ - `.swiftformat` for deterministic source formatting.
7
+
8
+ Both tools are provided by Qlty's default source. Consumers enable the `swiftlint` and `swiftformat` plugins and receive
9
+ these configurations from the Sine Macula source declared in `.qlty/qlty.toml`.
10
+
11
+ SwiftLint and SwiftFormat run through Qlty CLI only on macOS. Qlty Cloud still provides Swift maintainability analysis,
12
+ but its Linux workers cannot execute these two native plugins. A Swift repository therefore needs a macOS quality job
13
+ that runs both `qlty fmt --all` and `qlty check --all`.
14
+
15
+ The policy is not purely stock SwiftLint. Where the PHP and TypeScript standards in this repository express a house
16
+ opinion that SwiftLint can express too, it is carried over: a required copyright header (`file_header`), documentation
17
+ on the public surface (`missing_docs`), and comment prose held to the line limit. Opinions with no SwiftLint
18
+ equivalent - a maximum method count per type, protocol and boolean-method naming, and a required-readonly property
19
+ rule - are not enforced for Swift, and closing those would mean writing custom regex rules.
20
+
21
+ Application-specific paths, generated-source conventions, and architectural restrictions stay in the consuming
22
+ repository. The shared policy must remain usable by macOS apps, iOS apps, command-line tools, and Swift packages.
23
+
24
+ The standard deliberately avoids formatter rules that can change ownership, control flow, or an API declaration. A
25
+ formatter should make an equivalent program consistent; correctness and design changes belong in reviewed source
26
+ edits.
27
+
28
+ ## Deliberately not included
29
+
30
+ - StringsLint is deferred because its current Qlty integration does not support modern `.xcstrings` String Catalogs.
31
+ Reconsider it for a consumer that deliberately uses legacy `.strings` or `.stringsdict` resources.
32
+ - Semgrep rules belong here only after an organization-wide Swift security or architecture rule is defined. Product-
33
+ specific boundaries should stay in the product repository.
34
+ - Compiler-enforced policy such as strict concurrency, warnings-as-errors, deployment targets, and platform
35
+ availability remains in each Xcode project or shared project template; Qlty is not a substitute for `xcodebuild`.