@sinemacula/coding-standards 1.13.0 → 1.15.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
@@ -274,7 +274,7 @@ native directive - `// phpcs:ignore <code>` for a sniff, `@phpstan-ignore <ident
274
274
  | `SineMacula.Attributes.DisallowToolingAttribute` | No IDE/tooling attributes (e.g. `JetBrains\PhpStorm`). |
275
275
  | `SineMacula.Classes.RequireFinalClass` | Concrete classes must be `final` or `abstract` (`@inheritable` opts out). |
276
276
  | `SineMacula.Classes.RequireReadonlyPublicProperty` | Public properties (declared or promoted) must be `readonly`. |
277
- | `SineMacula.Commenting.CommentLineLength` | Standalone comment lines must not exceed 80 chars (FQCN/URL exempt). |
277
+ | `SineMacula.Commenting.CommentLineLength` | Standalone comment prose wrapped to 80 chars; premature wraps also fixed. |
278
278
  | `SineMacula.Commenting.ConsistentEnumCaseComments` | Enum case docs are all-or-nothing within an enum. |
279
279
  | `SineMacula.Commenting.MultilineMethodComment` | A method's doc comment must span multiple lines. |
280
280
  | `SineMacula.Commenting.RequireConstantComment` | Every class/interface/enum/trait constant needs a doc comment. |
@@ -321,6 +321,7 @@ type-checked layer.
321
321
  | `@sinemacula/align-doc-tags` | `@author` and `@copyright` values line up at a single column; autofixable. |
322
322
  | `@sinemacula/single-line-property-doc` | A data member's documentation comment sits on one line; autofixable. |
323
323
  | `@sinemacula/multiline-function-doc` | A method's documentation comment spans multiple lines; autofixable. |
324
+ | `@sinemacula/comment-line-wrap` | Standalone comment prose wrapped to 80 chars; premature wraps also fixed. |
324
325
 
325
326
  `boolean-method-name` takes `additionalPrefixes`, `additionalPredicates` and `additionalCommandVerbs` (string arrays)
326
327
  to widen the accepted vocabulary from a consumer config. `max-methods-per-class` takes `max`, `no-base-error` takes
@@ -331,8 +332,16 @@ members (interface property signatures, enum members and data class fields) take
331
332
  method signatures and class fields holding a function take several. A data comment is never required, only held to
332
333
  one line where present; a free function keeps the freedom of either shape.
333
334
 
334
- The base layer also switches on a set of built-in rules: `@typescript-eslint/no-explicit-any`, `max-lines-per-function`
335
- (50 lines, test code exempt) and `max-depth` (4), plus `eslint-plugin-jsdoc` rules that require a documentation comment
335
+ `comment-line-wrap` takes `maxLength` (default 80) and is the syntax-only counterpart of the PHP
336
+ `SineMacula.Commenting.CommentLineLength` sniff. It fills standalone `//` runs and multi-line docblock prose greedily,
337
+ reporting an overflowing line and a prematurely wrapped line on their own footings and autofixing both. Docblock tag
338
+ lines, suppression directives, fenced or indented code, tables, separators, a line whose overflow is a single
339
+ unbreakable token such as a long name or URL, trailing comments after code and compact single-line docblocks are left
340
+ untouched.
341
+
342
+ The base layer also switches on a set of built-in rules: `@typescript-eslint/no-explicit-any`, `curly` (a brace on every
343
+ control statement, as PSR-12 already requires on the PHP side), `max-lines-per-function` (50 lines, test code exempt)
344
+ and `max-depth` (4), plus `eslint-plugin-jsdoc` rules that require a documentation comment
336
345
  on every declared function, method, class, interface member and class field, forbid types in `@param`/`@returns` (the
337
346
  tags themselves are welcome, types belong in the signature) and keep a blank line above every documentation block,
338
347
  single-line blocks included. The type-checked layer adds `@typescript-eslint/explicit-module-boundary-types` and
@@ -52,7 +52,9 @@ export default [
52
52
  '@sinemacula/align-doc-tags': 'error',
53
53
  '@sinemacula/single-line-property-doc': 'error',
54
54
  '@sinemacula/multiline-function-doc': 'error',
55
+ '@sinemacula/comment-line-wrap': 'error',
55
56
 
57
+ curly: ['error', 'all'],
56
58
  'max-lines-per-function': ['error', { max: 50, skipComments: true, skipBlankLines: true, IIFEs: true }],
57
59
  'max-depth': ['error', 4],
58
60
 
@@ -92,7 +94,7 @@ export default [
92
94
  },
93
95
  },
94
96
  {
95
- files: ['**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}', '**/__tests__/**', '**/tests/**'],
97
+ files: ['**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}', '**/__tests__/**', '**/tests/**', '**/test-support/**'],
96
98
  rules: {
97
99
  'max-lines-per-function': 'off',
98
100
  'max-depth': 'off',
@@ -1,5 +1,6 @@
1
1
  import alignDocTags from './rules/align-doc-tags.js';
2
2
  import booleanMethodName from './rules/boolean-method-name.js';
3
+ import commentLineWrap from './rules/comment-line-wrap.js';
3
4
  import maxMethodsPerClass from './rules/max-methods-per-class.js';
4
5
  import multilineFunctionDoc from './rules/multiline-function-doc.js';
5
6
  import noBaseError from './rules/no-base-error.js';
@@ -36,5 +37,6 @@ export default {
36
37
  'align-doc-tags': alignDocTags,
37
38
  'single-line-property-doc': singleLinePropertyDoc,
38
39
  'multiline-function-doc': multilineFunctionDoc,
40
+ 'comment-line-wrap': commentLineWrap,
39
41
  },
40
42
  };
@@ -278,13 +278,12 @@ function buildListeners(state) {
278
278
  * guard, ...) that returns a result bool is exempt via COMMAND_VERBS. A member
279
279
  * may also opt out with an
280
280
  * @imperative docblock tag. An on* event-handler callback (onError) and a
281
- * method named after a primitive type (a typed accessor such as boolean())
282
- * are exempt, as are accessors, the constructor, computed names, magic names
283
- * and type-predicate guards (x is T). The return type is
284
- * resolved from type information - inferred booleans and awaited
285
- * Promise<boolean> included - so the rule degrades to a no-op when no type
286
- * information is available. The accepted vocabulary can be widened per consumer
287
- * through the rule options.
281
+ * method named after a primitive type (a typed accessor such as boolean()) are
282
+ * exempt, as are accessors, the constructor, computed names, magic names and
283
+ * type-predicate guards (x is T). The return type is resolved from type
284
+ * information - inferred booleans and awaited Promise<boolean> included - so
285
+ * the rule degrades to a no-op when no type information is available. The
286
+ * accepted vocabulary can be widened per consumer through the rule options.
288
287
  *
289
288
  * @author Ben Carey <bdmc@sinemacula.co.uk>
290
289
  * @copyright 2026 Sine Macula Limited
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Classifies a single content line of a comment.
3
+ *
4
+ * Only prose and list items are reflowed; every other kind is preserved
5
+ * verbatim and acts as a paragraph boundary. Directives, docblock tags, fenced
6
+ * or indented code, tables and rule separators are left exactly as written, so
7
+ * a reflow never disturbs a construct whose position or spacing carries
8
+ * meaning.
9
+ *
10
+ * @author Ben Carey <bdmc@sinemacula.co.uk>
11
+ * @copyright 2026 Sine Macula Limited
12
+ */
13
+
14
+ import { len, trimAscii } from './comment-tokenizer.js';
15
+
16
+ const WS = '[ \\t\\n\\r\\f\\v]';
17
+ const FENCE = /^(```|~~~)/;
18
+ const TAG = new RegExp(`^@[A-Za-z][A-Za-z0-9-]*(?=${WS}|$)`);
19
+ const LIST = new RegExp(`^${WS}*([-*+]|\\d+[.)])${WS}+`);
20
+ const DIRECTIVE = /^(phpcs:|phpstan-ignore|@phpstan-|@psalm-|@phan-|eslint-disable|eslint-enable|@ts-|prettier-ignore|stylelint-|@codingStandards|@SuppressWarnings|NOSONAR|qlty-ignore)/;
21
+ const SEPARATOR = /^[=\-~*_#.+ ]{3,}$/;
22
+ const CODE = new RegExp(`=>|->|::|;${WS}*$|\\{${WS}*$|^\\}|^(?:if|elseif|for|foreach|while|switch|catch)${WS}*\\(|^[\\w$>[\\]'.-]+${WS}*=[^=>]|^\\$`);
23
+ const SPAN = /`[^`]*`|\{@[^}]*\}|\[[^\]]*\]\([^)]*\)/g;
24
+
25
+ /** A list marker: a bullet or an ordered number, and the space after it. */
26
+ export const LIST_MARKER = new RegExp(`^(${WS}*)([-*+]|\\d+[.)])${WS}+`);
27
+
28
+ /** The kinds a comment content line can take. Only prose and lists reflow. */
29
+ export const KIND = {
30
+ BLANK: 'blank',
31
+ PROSE: 'prose',
32
+ LIST: 'list',
33
+ TAG: 'tag',
34
+ DIRECTIVE: 'directive',
35
+ SEPARATOR: 'separator',
36
+ TABLE: 'table',
37
+ FENCE: 'fence',
38
+ CODE: 'code',
39
+ };
40
+
41
+ /** The ordered rules mapping the first matching leading mark to a kind. */
42
+ const KINDS = [
43
+ { kind: KIND.FENCE, test: ({ trimmed }) => FENCE.test(trimmed) },
44
+ { kind: KIND.DIRECTIVE, test: ({ trimmed }) => DIRECTIVE.test(trimmed) },
45
+ { kind: KIND.TAG, test: ({ trimmed }) => TAG.test(trimmed) },
46
+ { kind: KIND.TABLE, test: ({ trimmed }) => trimmed.startsWith('|') },
47
+ { kind: KIND.SEPARATOR, test: ({ trimmed }) => SEPARATOR.test(trimmed) },
48
+ { kind: KIND.LIST, test: ({ content }) => LIST.test(content) },
49
+ { kind: KIND.CODE, test: ({ trimmed }) => CODE.test(trimmed.replace(SPAN, '')) },
50
+ ];
51
+
52
+ /**
53
+ * Classify a content line, given whether it sits inside a fenced block.
54
+ */
55
+ export function classify(content, inFence) {
56
+ const trimmed = trimAscii(content);
57
+
58
+ if (inFence) {
59
+ return KIND.FENCE;
60
+ }
61
+
62
+ return trimmed === '' ? KIND.BLANK : kindOf(content, trimmed);
63
+ }
64
+
65
+ /** Classify a non-blank line by its leading marks, defaulting to prose. */
66
+ function kindOf(content, trimmed) {
67
+ const line = { content, trimmed };
68
+
69
+ return KINDS.find(({ test }) => test(line))?.kind ?? KIND.PROSE;
70
+ }
71
+
72
+ /** Whether a content line opens or closes a fenced block. */
73
+ export function isFence(content) {
74
+ return FENCE.test(trimAscii(content));
75
+ }
76
+
77
+ /** Parse a list marker into its bullet, indent and occupied width, or null. */
78
+ export function listMarker(content) {
79
+ const match = LIST_MARKER.exec(content);
80
+
81
+ if (!match) {
82
+ return null;
83
+ }
84
+
85
+ return { marker: match[2], indent: len(match[1]), width: len(match[2]) + 1 };
86
+ }
@@ -0,0 +1,198 @@
1
+ import { createRule } from './lib.js';
2
+ import { reflow } from './comment-reflow.js';
3
+
4
+ const DEFAULT_MAX_LENGTH = 80;
5
+
6
+ /** A docblock interior line: its indent, the star, and the prose after it. */
7
+ const DOC_LINE = /^([ \t\n\r\f\v]*)\*( ?)(.*)$/;
8
+
9
+ /** Whether only whitespace precedes the comment on its own line. */
10
+ function isStandalone(comment, sourceCode) {
11
+ const line = sourceCode.lines[comment.loc.start.line - 1];
12
+
13
+ return line.slice(0, comment.loc.start.column).trim() === '';
14
+ }
15
+
16
+ /**
17
+ * Group standalone `//` comments into runs of adjacent lines sharing an indent,
18
+ * so a wrapped paragraph is reflowed as one unit.
19
+ */
20
+ function slashRuns(comments, sourceCode) {
21
+ const runs = [];
22
+ let current = null;
23
+
24
+ for (const comment of comments) {
25
+ if (comment.type !== 'Line' || !isStandalone(comment, sourceCode)) {
26
+ current = null;
27
+ continue;
28
+ }
29
+
30
+ const last = current?.[current.length - 1];
31
+
32
+ if (current && comment.loc.start.line === last.loc.start.line + 1 && comment.loc.start.column === current[0].loc.start.column) {
33
+ current.push(comment);
34
+ } else {
35
+ current = [comment];
36
+ runs.push(current);
37
+ }
38
+ }
39
+
40
+ return runs;
41
+ }
42
+
43
+ /** Strip the single optional space that follows a `//` from a comment's value. */
44
+ function slashContent(value) {
45
+ return value.startsWith(' ') ? value.slice(1) : value;
46
+ }
47
+
48
+ /** Describe a `//` run: its content lines, margin, report locations and rebuild. */
49
+ function slashDescriptor(run, sourceCode, eol) {
50
+ const first = run[0].loc.start;
51
+ const indent = sourceCode.lines[first.line - 1].slice(0, first.column);
52
+
53
+ return {
54
+ content: run.map(comment => slashContent(comment.value)),
55
+ marginWidth: indent.length + 3,
56
+ locs: run.map(comment => comment.loc),
57
+ range: [run[0].range[0], run[run.length - 1].range[1]],
58
+ rebuild: lines => lines.map((line, offset) => `${offset === 0 ? '' : indent}//${line === '' ? '' : ` ${line}`}`).join(eol),
59
+ };
60
+ }
61
+
62
+ /** The margin-stripped interior lines of a docblock, or null for an odd shape. */
63
+ function docContent(comment, sourceCode) {
64
+ const { lines } = sourceCode;
65
+ const open = comment.loc.start.line;
66
+ const close = comment.loc.end.line;
67
+
68
+ if (open === close || lines[open - 1].trim() !== '/**' || lines[close - 1].trim() !== '*/') {
69
+ return null;
70
+ }
71
+
72
+ const content = [];
73
+ const locs = [];
74
+ let indent = null;
75
+
76
+ for (let line = open + 1; line < close; line += 1) {
77
+ const match = DOC_LINE.exec(lines[line - 1]);
78
+
79
+ if (!match || (indent !== null && match[1] !== indent)) {
80
+ return null;
81
+ }
82
+
83
+ indent ??= match[1];
84
+ content.push(match[3]);
85
+ locs.push({ start: { line, column: 0 }, end: { line, column: lines[line - 1].length } });
86
+ }
87
+
88
+ return content.length === 0 ? null : { content, locs, indent };
89
+ }
90
+
91
+ /** Describe a docblock: its content lines, margin, report locations and rebuild. */
92
+ function docDescriptor(comment, sourceCode, eol) {
93
+ if (comment.type !== 'Block' || !comment.value.startsWith('*')) {
94
+ return null;
95
+ }
96
+
97
+ const parsed = docContent(comment, sourceCode);
98
+
99
+ if (parsed === null) {
100
+ return null;
101
+ }
102
+
103
+ const { content, locs, indent } = parsed;
104
+
105
+ return {
106
+ content,
107
+ marginWidth: indent.length + 2,
108
+ locs,
109
+ range: comment.range,
110
+ rebuild: lines => `/**${eol}${lines.map(line => (line === '' ? `${indent}*` : `${indent}* ${line}`)).join(eol)}${eol}${indent}*/`,
111
+ };
112
+ }
113
+
114
+ /** Reflow a block and report each overflowing and prematurely wrapped line. */
115
+ function enforce(context, descriptor, maxLength) {
116
+ const result = reflow(descriptor.content, descriptor.marginWidth, maxLength);
117
+
118
+ if (result.long.length === 0 && result.premature.length === 0) {
119
+ return;
120
+ }
121
+
122
+ /** Replace the whole comment block with its reflowed, canonical form. */
123
+ const fix = fixer => fixer.replaceTextRange(descriptor.range, descriptor.rebuild(result.lines));
124
+
125
+ /** Report one faulted line under the given message. */
126
+ const report = (index, messageId) => context.report({ loc: descriptor.locs[index], messageId, data: { max: maxLength }, fix });
127
+
128
+ result.long.forEach(index => report(index, 'tooLong'));
129
+ result.premature.forEach(index => report(index, 'prematureWrap'));
130
+ }
131
+
132
+ /**
133
+ * Keep standalone comment prose wrapped to a readable width.
134
+ *
135
+ * The syntax-only counterpart of the PHP comment line length sniff. It fills
136
+ * each line greedily with as many whole words as fit and reports two faults on
137
+ * their own footings: a line that overflows the width, and a line that wraps
138
+ * earlier than it needs to. Standalone `//` runs and multi-line docblocks are
139
+ * governed; tag lines, suppression directives, fenced or indented code, tables,
140
+ * separators and trailing comments after code are left untouched, as is a line
141
+ * whose overflow is a single unbreakable token such as a long name or URL, and
142
+ * a compact single-line docblock, which the single-line property rule governs.
143
+ * The fix reflows each faulted paragraph to its greedy canonical form and is
144
+ * idempotent.
145
+ *
146
+ * @author Ben Carey <bdmc@sinemacula.co.uk>
147
+ * @copyright 2026 Sine Macula Limited
148
+ */
149
+ export default createRule({
150
+ name: 'comment-line-wrap',
151
+ meta: {
152
+ type: 'layout',
153
+ fixable: 'whitespace',
154
+ docs: {
155
+ description: 'Keep standalone comment prose wrapped greedily to a readable width.',
156
+ },
157
+ schema: [
158
+ {
159
+ type: 'object',
160
+ properties: {
161
+ maxLength: {
162
+ type: 'integer',
163
+ minimum: 1,
164
+ },
165
+ },
166
+ additionalProperties: false,
167
+ },
168
+ ],
169
+ messages: {
170
+ tooLong: 'Comment line must not exceed {{ max }} characters.',
171
+ prematureWrap: 'Comment line wraps before it needs to; the next word fits within {{ max }} characters.',
172
+ },
173
+ },
174
+ defaultOptions: [{ maxLength: DEFAULT_MAX_LENGTH }],
175
+ create(context, [options]) {
176
+ const { sourceCode } = context;
177
+ const maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH;
178
+ const eol = sourceCode.text.includes('\r\n') ? '\r\n' : '\n';
179
+
180
+ return {
181
+ Program() {
182
+ const comments = sourceCode.getAllComments();
183
+
184
+ for (const run of slashRuns(comments, sourceCode)) {
185
+ enforce(context, slashDescriptor(run, sourceCode, eol), maxLength);
186
+ }
187
+
188
+ for (const comment of comments) {
189
+ const descriptor = docDescriptor(comment, sourceCode, eol);
190
+
191
+ if (descriptor !== null) {
192
+ enforce(context, descriptor, maxLength);
193
+ }
194
+ }
195
+ },
196
+ };
197
+ },
198
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Reflows a single paragraph of comment prose to its greedy canonical form.
3
+ *
4
+ * A paragraph is a run of prose lines, or one list item with its continuation
5
+ * lines. It is filled line by line with as many whole words as the width
6
+ * allows, never splitting a word and keeping any list marker's hanging indent.
7
+ * A paragraph is reflowed only when it is safe to: it holds no token wider than
8
+ * the line, is not too narrow, and would still read as prose once wrapped.
9
+ * Otherwise it is returned untouched.
10
+ *
11
+ * @author Ben Carey <bdmc@sinemacula.co.uk>
12
+ * @copyright 2026 Sine Macula Limited
13
+ */
14
+
15
+ import { KIND, LIST_MARKER, classify } from './comment-classifier.js';
16
+ import { len, tokenize, trimAsciiStart } from './comment-tokenizer.js';
17
+
18
+ const POISON = /^(@[A-Za-z][A-Za-z0-9-]*|[-*+]|\d+[.)])$/;
19
+
20
+ /**
21
+ * Reflow a paragraph, returning its output lines and the faults found, their
22
+ * indices offset from the given base, or the verbatim input when it cannot be
23
+ * reflowed or carries no fault.
24
+ */
25
+ export function reflowParagraph(slice, marker, marginWidth, maxLength, base) {
26
+ const wrapped = wrapLines(slice, marker, marginWidth, maxLength);
27
+ const faults = wrapped === null ? { long: [], premature: [] } : findFaults(slice, marginWidth, maxLength);
28
+
29
+ if (wrapped === null || (faults.long.length === 0 && faults.premature.length === 0)) {
30
+ return { lines: slice, long: [], premature: [] };
31
+ }
32
+
33
+ return {
34
+ lines: wrapped,
35
+ long: faults.long.map(offset => base + offset),
36
+ premature: faults.premature.map(offset => base + offset),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * The greedy canonical lines of a paragraph, or null when it holds an
42
+ * unwrappable token, is too narrow, or would re-classify once wrapped.
43
+ */
44
+ function wrapLines(slice, marker, marginWidth, maxLength) {
45
+ const indent = marker === null ? leadingSpaces(slice[0]) : marker.indent;
46
+ const hanging = indent + (marker === null ? 0 : marker.width);
47
+ const width = maxLength - marginWidth - hanging;
48
+ const tokens = glue(tokenize(joinText(slice, marker)));
49
+
50
+ if (width < 1 || tokens.length === 0 || longestToken(tokens) > width) {
51
+ return null;
52
+ }
53
+
54
+ const lines = layout(greedy(tokens, width), marker, indent, hanging);
55
+
56
+ return stable(lines, marker) ? lines : null;
57
+ }
58
+
59
+ /** Assemble output lines, prefixing the marker then the hanging indent. */
60
+ function layout(wrapped, marker, indent, hanging) {
61
+ return wrapped.map((text, offset) =>
62
+ offset === 0 && marker !== null
63
+ ? `${' '.repeat(indent)}${marker.marker} ${text}`
64
+ : `${' '.repeat(hanging)}${text}`,
65
+ );
66
+ }
67
+
68
+ /** Whether every reflowed line still classifies as it must. */
69
+ function stable(lines, marker) {
70
+ return lines.every((line, offset) => {
71
+ const expected = offset === 0 && marker !== null ? KIND.LIST : KIND.PROSE;
72
+
73
+ return classify(line, false) === expected;
74
+ });
75
+ }
76
+
77
+ /** The input offsets that overflow the width and those that wrap early. */
78
+ function findFaults(slice, marginWidth, maxLength) {
79
+ const long = [];
80
+ const premature = [];
81
+
82
+ slice.forEach((content, offset) => {
83
+ if (marginWidth + len(content) > maxLength) {
84
+ long.push(offset);
85
+ }
86
+ if (wrapsEarly(slice, offset, marginWidth, maxLength)) {
87
+ premature.push(offset);
88
+ }
89
+ });
90
+
91
+ return { long, premature };
92
+ }
93
+
94
+ /** Whether a line could hold the first word of the next line within the width. */
95
+ function wrapsEarly(slice, offset, marginWidth, maxLength) {
96
+ if (offset + 1 >= slice.length) {
97
+ return false;
98
+ }
99
+
100
+ const next = glue(tokenize(slice[offset + 1]))[0] ?? '';
101
+
102
+ return next !== '' && marginWidth + len(slice[offset]) + 1 + len(next) <= maxLength;
103
+ }
104
+
105
+ /** Join a paragraph's lines, stripping any list marker from the first line. */
106
+ function joinText(slice, marker) {
107
+ const first = marker === null ? trimAsciiStart(slice[0]) : slice[0].replace(LIST_MARKER, '');
108
+ const rest = slice.slice(1).map(line => trimAsciiStart(line));
109
+
110
+ return [first, ...rest].join(' ');
111
+ }
112
+
113
+ /** Merge backward any token that must never open a wrapped line. */
114
+ function glue(tokens) {
115
+ const out = [];
116
+
117
+ for (const token of tokens) {
118
+ if (out.length > 0 && POISON.test(token)) {
119
+ out[out.length - 1] += ` ${token}`;
120
+ } else {
121
+ out.push(token);
122
+ }
123
+ }
124
+
125
+ return out;
126
+ }
127
+
128
+ /** Fill lines greedily with whole tokens up to the width. */
129
+ function greedy(tokens, width) {
130
+ const lines = [];
131
+ let current = '';
132
+
133
+ for (const token of tokens) {
134
+ if (current === '') {
135
+ current = token;
136
+ } else if (len(current) + 1 + len(token) <= width) {
137
+ current += ` ${token}`;
138
+ } else {
139
+ lines.push(current);
140
+ current = token;
141
+ }
142
+ }
143
+
144
+ if (current !== '') {
145
+ lines.push(current);
146
+ }
147
+
148
+ return lines;
149
+ }
150
+
151
+ /** The length of the longest token. */
152
+ function longestToken(tokens) {
153
+ return Math.max(...tokens.map(len));
154
+ }
155
+
156
+ /** The number of leading spaces on a line. */
157
+ function leadingSpaces(text) {
158
+ return len(text) - len(trimAsciiStart(text));
159
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Deterministic greedy re-wrapper for comment prose.
3
+ *
4
+ * The syntax-only counterpart of the PHP comment line length sniff: given the
5
+ * content lines of one comment block (their margin already stripped) and the
6
+ * width that margin will reclaim, it walks the block and hands each run of
7
+ * prose and each list item to a paragraph reflow, gathering the canonical lines
8
+ * and the indices of the input lines that overflow or wrap prematurely. Tags,
9
+ * directives, fenced or indented code, tables and separators are left verbatim
10
+ * and bound the paragraph around them.
11
+ *
12
+ * Widths count Unicode code points and whitespace is matched as ASCII only, so
13
+ * the output is byte-for-byte identical to the PHP engine (mb_strlen and the
14
+ * non-unicode PCRE `\s`).
15
+ *
16
+ * @author Ben Carey <bdmc@sinemacula.co.uk>
17
+ * @copyright 2026 Sine Macula Limited
18
+ */
19
+
20
+ import { KIND, classify, isFence, listMarker } from './comment-classifier.js';
21
+ import { reflowParagraph } from './comment-paragraph.js';
22
+
23
+ export { KIND, classify, isFence, listMarker } from './comment-classifier.js';
24
+ export { tokenize } from './comment-tokenizer.js';
25
+
26
+ /**
27
+ * Reflow a block's content lines to the greedy canonical form, returning the
28
+ * canonical lines and the indices of the input lines that overflow or wrap
29
+ * prematurely.
30
+ */
31
+ export function reflow(lines, marginWidth, maxLength) {
32
+ const out = [];
33
+ const long = [];
34
+ const premature = [];
35
+ let inFence = false;
36
+ let i = 0;
37
+
38
+ while (i < lines.length) {
39
+ const type = inFence ? KIND.FENCE : classify(lines[i], false);
40
+
41
+ if (inFence) {
42
+ out.push(lines[i]);
43
+ inFence = !isFence(lines[i]);
44
+ i += 1;
45
+ } else {
46
+ i = consume(lines, i, type, { out, long, premature, marginWidth, maxLength, fence: value => { inFence = value; } });
47
+ }
48
+ }
49
+
50
+ return { lines: out, long, premature };
51
+ }
52
+
53
+ /** Handle one line or paragraph, appending its output and any faults. */
54
+ function consume(lines, i, type, ctx) {
55
+ if (type === KIND.PROSE) {
56
+ return paragraph(lines, i, null, ctx);
57
+ }
58
+ if (type === KIND.LIST) {
59
+ return paragraph(lines, i, listMarker(lines[i]), ctx);
60
+ }
61
+
62
+ ctx.out.push(lines[i]);
63
+
64
+ if (type === KIND.FENCE) {
65
+ ctx.fence(true);
66
+ }
67
+
68
+ return i + 1;
69
+ }
70
+
71
+ /**
72
+ * Append a run of prose or a list item, reflowed where it faults. A list line
73
+ * always parses to a marker, so it never reaches the plain-prose branch.
74
+ */
75
+ function paragraph(lines, start, marker, ctx) {
76
+ const end = proseEnd(lines, marker === null ? start : start + 1);
77
+ const slice = lines.slice(start, end);
78
+ const segment = reflowParagraph(slice, marker, ctx.marginWidth, ctx.maxLength, start);
79
+
80
+ ctx.out.push(...segment.lines);
81
+ segment.long.forEach(offset => ctx.long.push(offset));
82
+ segment.premature.forEach(offset => ctx.premature.push(offset));
83
+
84
+ return end;
85
+ }
86
+
87
+ /** The index one past the last prose line from the given start. */
88
+ function proseEnd(lines, from) {
89
+ let end = from;
90
+
91
+ while (end < lines.length && classify(lines[end], false) === KIND.PROSE) {
92
+ end += 1;
93
+ }
94
+
95
+ return end;
96
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Text primitives for the comment reflow engine.
3
+ *
4
+ * Splits prose into wrapping tokens, keeping inline code, an inline `{@link}`
5
+ * tag and a Markdown link whole even when they hold spaces, and measures widths
6
+ * in Unicode code points. Widths and whitespace match the PHP engine exactly:
7
+ * code points as mb_strlen counts them, and ASCII-only trimming as trim does.
8
+ *
9
+ * @author Ben Carey <bdmc@sinemacula.co.uk>
10
+ * @copyright 2026 Sine Macula Limited
11
+ */
12
+
13
+ const ASCII_TRIM = /^[ \t\n\r\0\v]+|[ \t\n\r\0\v]+$/g;
14
+ const ASCII_TRIM_START = /^[ \t\n\r\0\v]+/;
15
+
16
+ /** The number of Unicode code points in a string, matching PHP mb_strlen. */
17
+ export function len(text) {
18
+ return [...text].length;
19
+ }
20
+
21
+ /** Strip leading and trailing ASCII whitespace, matching PHP trim. */
22
+ export function trimAscii(text) {
23
+ return text.replace(ASCII_TRIM, '');
24
+ }
25
+
26
+ /** Strip leading ASCII whitespace, matching PHP ltrim. */
27
+ export function trimAsciiStart(text) {
28
+ return text.replace(ASCII_TRIM_START, '');
29
+ }
30
+
31
+ /**
32
+ * Split a line of prose into tokens, keeping inline code, link tags and
33
+ * Markdown links whole even when they hold spaces.
34
+ */
35
+ export function tokenize(text) {
36
+ const chars = [...text];
37
+ const tokens = [];
38
+ let i = 0;
39
+
40
+ while (i < chars.length) {
41
+ if (chars[i] === ' ') {
42
+ i += 1;
43
+ continue;
44
+ }
45
+
46
+ const start = i;
47
+ i = consumeToken(chars, i);
48
+ tokens.push(chars.slice(start, i).join(''));
49
+ }
50
+
51
+ return tokens;
52
+ }
53
+
54
+ /** Advance past one token, stepping over any atomic span it contains. */
55
+ function consumeToken(chars, i) {
56
+ while (i < chars.length && chars[i] !== ' ') {
57
+ if (chars[i] === '`') {
58
+ i = consumeSpan(chars, i, '`');
59
+ } else if (chars[i] === '{' && chars[i + 1] === '@') {
60
+ i = consumeSpan(chars, i, '}');
61
+ } else if (chars[i] === '[') {
62
+ i = consumeLink(chars, i);
63
+ } else {
64
+ i += 1;
65
+ }
66
+ }
67
+
68
+ return i;
69
+ }
70
+
71
+ /** Advance past a delimited span to its closer, or by one when it never closes. */
72
+ function consumeSpan(chars, i, close) {
73
+ const found = chars.indexOf(close, i + 1);
74
+
75
+ return found === -1 ? i + 1 : found + 1;
76
+ }
77
+
78
+ /** Advance past a Markdown link, or by one when the shape does not hold. */
79
+ function consumeLink(chars, i) {
80
+ const label = consumeSpan(chars, i, ']');
81
+
82
+ if (label === i + 1 || chars[label] !== '(') {
83
+ return i + 1;
84
+ }
85
+
86
+ const target = consumeSpan(chars, label, ')');
87
+
88
+ return target === label + 1 ? i + 1 : target;
89
+ }
package/js/eslint/vue.js CHANGED
@@ -8,14 +8,14 @@ import tseslint from 'typescript-eslint';
8
8
  * through the TypeScript parser, and holds component filenames to the same
9
9
  * kebab-case convention the shared formatter enforces on plain sources.
10
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.
11
+ * This layer also carries the template layout rules, which the shared formatter
12
+ * cannot: it does not understand single-file components, so `.vue` markup would
13
+ * otherwise go unformatted entirely.
14
14
  *
15
15
  * Additive, and deliberately unlike the type-aware layer: it carries no base
16
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.
17
+ * rather than in place of one. Vue is orthogonal to type-awareness, and a repo
18
+ * enabling both would otherwise apply the base rules twice.
19
19
  *
20
20
  * @author Ben Carey <bdmc@sinemacula.co.uk>
21
21
  * @copyright 2026 Sine Macula Limited
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sinemacula/coding-standards",
3
- "version": "1.13.0",
3
+ "version": "1.15.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>",