@sinemacula/coding-standards 1.15.0 → 1.17.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
@@ -334,10 +334,12 @@ one line where present; a free function keeps the freedom of either shape.
334
334
 
335
335
  `comment-line-wrap` takes `maxLength` (default 80) and is the syntax-only counterpart of the PHP
336
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.
337
+ reporting an overflowing line and a prematurely wrapped line on their own footings and autofixing both. Markdown
338
+ headings, docblock tag lines, machine-parsed tool directives (`eslint`, `biome-ignore`, `@ts-`, `Stryker`, `c8`/`v8`/
339
+ `istanbul ignore`, `@vite-ignore` and the like), fenced code, an indented code or command block, a doc-tag whose
340
+ value opens a multi-line bracketed type (an `array{...}` shape, a `<...>` generic or a `\Closure(...)` signature),
341
+ tables, separators, a line whose overflow is a single unbreakable token such as a long name or URL, trailing comments
342
+ after code and compact single-line docblocks are left untouched.
341
343
 
342
344
  The base layer also switches on a set of built-in rules: `@typescript-eslint/no-explicit-any`, `curly` (a brace on every
343
345
  control statement, as PSR-12 already requires on the PHP side), `max-lines-per-function` (50 lines, test code exempt)
@@ -17,10 +17,19 @@ const WS = '[ \\t\\n\\r\\f\\v]';
17
17
  const FENCE = /^(```|~~~)/;
18
18
  const TAG = new RegExp(`^@[A-Za-z][A-Za-z0-9-]*(?=${WS}|$)`);
19
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)/;
20
+ const HEADING = new RegExp(`^#{1,6}${WS}`);
21
+ const DIRECTIVE = /^(phpcs:|phpstan-ignore|@phpstan-|@psalm-|@phan-|eslint\b|globals?\b|exported\b|biome-ignore\b|@ts-|prettier-ignore|stylelint-|Stryker (?:disable|restore)\b|(?:c8|v8|istanbul) ignore\b|@vite-ignore\b|webpackChunkName\b|@preserve\b|@license\b|@codingStandards|@SuppressWarnings|NOSONAR|qlty-ignore)/;
21
22
  const SEPARATOR = /^[=\-~*_#.+ ]{3,}$/;
22
23
  const CODE = new RegExp(`=>|->|::|;${WS}*$|\\{${WS}*$|^\\}|^(?:if|elseif|for|foreach|while|switch|catch)${WS}*\\(|^[\\w$>[\\]'.-]+${WS}*=[^=>]|^\\$`);
23
24
  const SPAN = /`[^`]*`|\{@[^}]*\}|\[[^\]]*\]\([^)]*\)/g;
25
+ const TYPE_TAG = /^@(?:(?:phpstan|psalm|phan)-(?:type|import-type|param|return|var)|var|param|return|typedef|type|property|returns)\b/;
26
+ const TYPE_CONTINUES = /[{<(,:|&]\s*$/;
27
+ const QUOTE = /'[^']*'|"[^"]*"/g;
28
+ const INDENT = /^(?: {2,}|\t)/;
29
+ const LEADING_SPACES = /^ +/;
30
+ const ARROWS = /->|=>/g;
31
+ const OPENERS = /[{<(]/g;
32
+ const CLOSERS = /[}>)]/g;
24
33
 
25
34
  /** A list marker: a bullet or an ordered number, and the space after it. */
26
35
  export const LIST_MARKER = new RegExp(`^(${WS}*)([-*+]|\\d+[.)])${WS}+`);
@@ -36,6 +45,7 @@ export const KIND = {
36
45
  TABLE: 'table',
37
46
  FENCE: 'fence',
38
47
  CODE: 'code',
48
+ HEADING: 'heading',
39
49
  };
40
50
 
41
51
  /** The ordered rules mapping the first matching leading mark to a kind. */
@@ -44,6 +54,7 @@ const KINDS = [
44
54
  { kind: KIND.DIRECTIVE, test: ({ trimmed }) => DIRECTIVE.test(trimmed) },
45
55
  { kind: KIND.TAG, test: ({ trimmed }) => TAG.test(trimmed) },
46
56
  { kind: KIND.TABLE, test: ({ trimmed }) => trimmed.startsWith('|') },
57
+ { kind: KIND.HEADING, test: ({ trimmed }) => HEADING.test(trimmed) },
47
58
  { kind: KIND.SEPARATOR, test: ({ trimmed }) => SEPARATOR.test(trimmed) },
48
59
  { kind: KIND.LIST, test: ({ content }) => LIST.test(content) },
49
60
  { kind: KIND.CODE, test: ({ trimmed }) => CODE.test(trimmed.replace(SPAN, '')) },
@@ -84,3 +95,46 @@ export function listMarker(content) {
84
95
 
85
96
  return { marker: match[2], indent: len(match[1]), width: len(match[2]) + 1 };
86
97
  }
98
+
99
+ /** Whether a content line is indented past the baseline: a tab or two spaces. */
100
+ export function indented(content) {
101
+ return INDENT.test(content);
102
+ }
103
+
104
+ /** The number of leading spaces on a content line, for the hanging-indent bound. */
105
+ export function indentWidth(content) {
106
+ return content.length - content.replace(LEADING_SPACES, '').length;
107
+ }
108
+
109
+ /** Whether a line ends the verbatim type block before it: a blank line or a fresh tag. */
110
+ export function isTypeBoundary(content) {
111
+ const trimmed = trimAscii(content);
112
+
113
+ return trimmed === '' || trimmed.startsWith('@');
114
+ }
115
+
116
+ /** The net depth a line's type slot opens, ignoring inline spans and arrows. */
117
+ export function bracketDelta(text) {
118
+ const stripped = typeSlot(text).replace(ARROWS, '');
119
+
120
+ return (stripped.match(OPENERS) ?? []).length - (stripped.match(CLOSERS) ?? []).length;
121
+ }
122
+
123
+ /** The depth a type-annotation tag opens, or zero when it is not one or closes here. */
124
+ export function typeTagDepth(content) {
125
+ if (!TYPE_TAG.test(trimAscii(content))) {
126
+ return 0;
127
+ }
128
+
129
+ const delta = bracketDelta(content);
130
+
131
+ return delta > 0 && TYPE_CONTINUES.test(typeSlot(content)) ? delta : 0;
132
+ }
133
+
134
+ /** The type-carrying portion of a line: spans and quotes removed, and everything from the first variable dropped. */
135
+ function typeSlot(content) {
136
+ const stripped = content.replace(SPAN, '').replace(QUOTE, '');
137
+ const variable = stripped.indexOf('$');
138
+
139
+ return variable === -1 ? stripped : stripped.slice(0, variable);
140
+ }
@@ -15,7 +15,7 @@
15
15
  import { KIND, LIST_MARKER, classify } from './comment-classifier.js';
16
16
  import { len, tokenize, trimAsciiStart } from './comment-tokenizer.js';
17
17
 
18
- const POISON = /^(@[A-Za-z][A-Za-z0-9-]*|[-*+]|\d+[.)])$/;
18
+ const POISON = /^(@[A-Za-z][A-Za-z0-9-]*|[-*+]|\d+[.)]|#{1,6})$/;
19
19
 
20
20
  /**
21
21
  * Reflow a paragraph, returning its output lines and the faults found, their
@@ -5,9 +5,10 @@
5
5
  * content lines of one comment block (their margin already stripped) and the
6
6
  * width that margin will reclaim, it walks the block and hands each run of
7
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.
8
+ * and the indices of the input lines that overflow or wrap prematurely. A
9
+ * fenced block, a type-annotation tag whose value opens a bracketed type across
10
+ * lines, headings, indented code, tags, directives, tables and separators are
11
+ * left verbatim and bound the paragraph around them.
11
12
  *
12
13
  * Widths count Unicode code points and whitespace is matched as ASCII only, so
13
14
  * the output is byte-for-byte identical to the PHP engine (mb_strlen and the
@@ -17,7 +18,7 @@
17
18
  * @copyright 2026 Sine Macula Limited
18
19
  */
19
20
 
20
- import { KIND, classify, isFence, listMarker } from './comment-classifier.js';
21
+ import { KIND, bracketDelta, classify, indentWidth, indented, isFence, isTypeBoundary, listMarker, typeTagDepth } from './comment-classifier.js';
21
22
  import { reflowParagraph } from './comment-paragraph.js';
22
23
 
23
24
  export { KIND, classify, isFence, listMarker } from './comment-classifier.js';
@@ -29,42 +30,73 @@ export { tokenize } from './comment-tokenizer.js';
29
30
  * prematurely.
30
31
  */
31
32
  export function reflow(lines, marginWidth, maxLength) {
32
- const out = [];
33
- const long = [];
34
- const premature = [];
35
- let inFence = false;
33
+ const ctx = { out: [], long: [], premature: [], marginWidth, maxLength, inFence: false, typeDepth: 0 };
36
34
  let i = 0;
37
35
 
38
36
  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
- }
37
+ i = step(lines, i, ctx);
48
38
  }
49
39
 
50
- return { lines: out, long, premature };
40
+ return { lines: ctx.out, long: ctx.long, premature: ctx.premature };
51
41
  }
52
42
 
53
- /** Handle one line or paragraph, appending its output and any faults. */
54
- function consume(lines, i, type, ctx) {
43
+ /**
44
+ * Advance one line, keeping verbatim any line inside a fenced block or inside a
45
+ * type-annotation tag whose bracketed value is still open.
46
+ */
47
+ function step(lines, i, ctx) {
48
+ const line = lines[i];
49
+
50
+ if (ctx.inFence) {
51
+ ctx.inFence = !isFence(line);
52
+
53
+ return verbatim(line, i, ctx);
54
+ }
55
+
56
+ if (ctx.typeDepth > 0 && !isTypeBoundary(line)) {
57
+ ctx.typeDepth = Math.max(0, ctx.typeDepth + bracketDelta(line));
58
+
59
+ return verbatim(line, i, ctx);
60
+ }
61
+
62
+ ctx.typeDepth = 0;
63
+
64
+ const opening = typeTagDepth(line);
65
+
66
+ if (opening > 0) {
67
+ ctx.typeDepth = opening;
68
+
69
+ return verbatim(line, i, ctx);
70
+ }
71
+
72
+ return consume(lines, i, ctx);
73
+ }
74
+
75
+ /** Dispatch a line by kind: an indented line is verbatim; else fence, prose or list. */
76
+ function consume(lines, i, ctx) {
77
+ if (indented(lines[i])) {
78
+ return verbatim(lines[i], i, ctx);
79
+ }
80
+
81
+ const type = classify(lines[i], false);
82
+
55
83
  if (type === KIND.PROSE) {
56
84
  return paragraph(lines, i, null, ctx);
57
85
  }
58
86
  if (type === KIND.LIST) {
59
87
  return paragraph(lines, i, listMarker(lines[i]), ctx);
60
88
  }
61
-
62
- ctx.out.push(lines[i]);
63
-
64
89
  if (type === KIND.FENCE) {
65
- ctx.fence(true);
90
+ ctx.inFence = true;
66
91
  }
67
92
 
93
+ return verbatim(lines[i], i, ctx);
94
+ }
95
+
96
+ /** Emit one line unchanged and advance past it. */
97
+ function verbatim(line, i, ctx) {
98
+ ctx.out.push(line);
99
+
68
100
  return i + 1;
69
101
  }
70
102
 
@@ -73,7 +105,7 @@ function consume(lines, i, type, ctx) {
73
105
  * always parses to a marker, so it never reaches the plain-prose branch.
74
106
  */
75
107
  function paragraph(lines, start, marker, ctx) {
76
- const end = proseEnd(lines, marker === null ? start : start + 1);
108
+ const end = marker === null ? flushEnd(lines, start) : continuationEnd(lines, start + 1, marker.indent + marker.width);
77
109
  const slice = lines.slice(start, end);
78
110
  const segment = reflowParagraph(slice, marker, ctx.marginWidth, ctx.maxLength, start);
79
111
 
@@ -84,11 +116,28 @@ function paragraph(lines, start, marker, ctx) {
84
116
  return end;
85
117
  }
86
118
 
87
- /** The index one past the last prose line from the given start. */
88
- function proseEnd(lines, from) {
119
+ /**
120
+ * The index one past the last flush prose line from the given start. An
121
+ * indented line ends the paragraph, bounding a verbatim indented code block.
122
+ */
123
+ function flushEnd(lines, from) {
124
+ let end = from;
125
+
126
+ while (end < lines.length && classify(lines[end], false) === KIND.PROSE && !indented(lines[end])) {
127
+ end += 1;
128
+ }
129
+
130
+ return end;
131
+ }
132
+
133
+ /**
134
+ * The index one past the last continuation line of a list item. A line indented
135
+ * past the hanging indent is verbatim code, not a continuation.
136
+ */
137
+ function continuationEnd(lines, from, hanging) {
89
138
  let end = from;
90
139
 
91
- while (end < lines.length && classify(lines[end], false) === KIND.PROSE) {
140
+ while (end < lines.length && classify(lines[end], false) === KIND.PROSE && indentWidth(lines[end]) <= hanging) {
92
141
  end += 1;
93
142
  }
94
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sinemacula/coding-standards",
3
- "version": "1.15.0",
3
+ "version": "1.17.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>",