@blumintinc/eslint-plugin-blumint 1.20.190 → 1.20.192

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/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.190',
226
+ version: '1.20.192',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -2,32 +2,104 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceTypescriptMarkdownCodeBlocks = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
- const FENCE = '```';
6
- function isFenceLine(text, fenceIndex, indent) {
7
- const lineEnd = text.indexOf('\n', fenceIndex);
8
- const afterFence = lineEnd === -1
9
- ? text.slice(fenceIndex + FENCE.length)
10
- : text.slice(fenceIndex + FENCE.length, lineEnd);
11
- return (afterFence.trim().length === 0 &&
12
- text.slice(text.lastIndexOf('\n', fenceIndex - 1) + 1, fenceIndex) ===
13
- indent);
5
+ const BACKTICK = '`';
6
+ const TILDE = '~';
7
+ /** Only a run of exactly three backticks is labelable; longer runs are declined. */
8
+ const LABELABLE_FENCE_LENGTH = 3;
9
+ /** CommonMark opens a fenced block on a run of three or more of either marker. */
10
+ const MIN_FENCE_LENGTH = 3;
11
+ /**
12
+ * CommonMark allows a fence to be indented at most three columns. At four or
13
+ * more the line opens an indented code block, so its backticks are literal
14
+ * document content that must never be rewritten.
15
+ */
16
+ const MAX_FENCE_INDENT_COLUMNS = 3;
17
+ /** CommonMark advances a tab to the next multiple of four when measuring indent. */
18
+ const TAB_STOP = 4;
19
+ function splitLines(text) {
20
+ const lines = [];
21
+ let start = 0;
22
+ for (;;) {
23
+ const terminator = text.indexOf('\n', start);
24
+ const end = terminator === -1 ? text.length : terminator;
25
+ lines.push({ start, end, text: text.slice(start, end) });
26
+ if (terminator === -1) {
27
+ return lines;
28
+ }
29
+ start = terminator + 1;
30
+ }
14
31
  }
15
- function findClosingFence(text, startIndex, indent) {
16
- let searchIndex = startIndex;
17
- while (searchIndex < text.length) {
18
- const candidate = text.indexOf(FENCE, searchIndex);
19
- if (candidate === -1) {
20
- return null;
32
+ /**
33
+ * Reads the fence a line opens, or null when the line cannot open one.
34
+ * Indentation is measured in columns rather than characters so that a tab is
35
+ * treated as the four-column indent CommonMark says it is.
36
+ */
37
+ function readFence(line) {
38
+ let indentColumns = 0;
39
+ let offset = 0;
40
+ while (offset < line.text.length) {
41
+ const char = line.text[offset];
42
+ if (char === ' ') {
43
+ indentColumns += 1;
21
44
  }
22
- if (isFenceLine(text, candidate, indent)) {
23
- return candidate;
45
+ else if (char === '\t') {
46
+ indentColumns += TAB_STOP - (indentColumns % TAB_STOP);
47
+ }
48
+ else {
49
+ break;
50
+ }
51
+ offset += 1;
52
+ }
53
+ if (indentColumns > MAX_FENCE_INDENT_COLUMNS) {
54
+ return null;
55
+ }
56
+ const marker = line.text[offset];
57
+ if (marker !== BACKTICK && marker !== TILDE) {
58
+ return null;
59
+ }
60
+ let runLength = 0;
61
+ while (line.text[offset + runLength] === marker) {
62
+ runLength += 1;
63
+ }
64
+ if (runLength < MIN_FENCE_LENGTH) {
65
+ return null;
66
+ }
67
+ return {
68
+ runStart: line.start + offset,
69
+ marker,
70
+ runLength,
71
+ indent: line.text.slice(0, offset),
72
+ infoString: line.text.slice(offset + runLength),
73
+ };
74
+ }
75
+ /**
76
+ * The line a block closes on, per CommonMark: a run of at least the opening
77
+ * length, of the SAME marker, at a fence indent, with nothing but whitespace
78
+ * after it. This locates the block's END, which is a separate question from
79
+ * whether the rule is willing to LABEL it.
80
+ */
81
+ function findFenceCloser(lines, fromLine, opener) {
82
+ for (let index = fromLine; index < lines.length; index++) {
83
+ const fence = readFence(lines[index]);
84
+ if (fence !== null &&
85
+ fence.marker === opener.marker &&
86
+ fence.runLength >= opener.runLength &&
87
+ fence.infoString.trim().length === 0) {
88
+ return { line: index, fence };
24
89
  }
25
- searchIndex = candidate + FENCE.length;
26
90
  }
27
91
  return null;
28
92
  }
29
- function isIndentOnly(value) {
30
- return /^[\t ]*$/.test(value);
93
+ /**
94
+ * The rule labels only a block it can delimit exactly: three backticks closed
95
+ * by three backticks at the same indent. A longer closing run or a differently
96
+ * indented one is left unlabeled by design — but the block is still SKIPPED
97
+ * whole, because a block the rule declines to label is a block it must not
98
+ * read.
99
+ */
100
+ function isExactlyDelimited(opener, closer) {
101
+ return (closer.runLength === LABELABLE_FENCE_LENGTH &&
102
+ closer.indent === opener.indent);
31
103
  }
32
104
  exports.enforceTypescriptMarkdownCodeBlocks = (0, createRule_1.createRule)({
33
105
  name: 'enforce-typescript-markdown-code-blocks',
@@ -53,45 +125,51 @@ exports.enforceTypescriptMarkdownCodeBlocks = (0, createRule_1.createRule)({
53
125
  Program() {
54
126
  const sourceCode = context.sourceCode;
55
127
  const text = sourceCode.getText();
128
+ const lines = splitLines(text);
56
129
  let index = 0;
57
- while (index < text.length) {
58
- const openingFence = text.indexOf(FENCE, index);
59
- if (openingFence === -1) {
60
- break;
61
- }
62
- const lineStart = text.lastIndexOf('\n', openingFence - 1) + 1;
63
- const indent = text.slice(lineStart, openingFence);
64
- if (!isIndentOnly(indent)) {
65
- index = openingFence + FENCE.length;
130
+ while (index < lines.length) {
131
+ const openingLine = lines[index];
132
+ const fence = readFence(openingLine);
133
+ if (fence === null) {
134
+ index += 1;
66
135
  continue;
67
136
  }
68
- const lineEnd = text.indexOf('\n', openingFence + FENCE.length);
69
- if (lineEnd === -1) {
70
- break;
71
- }
72
- const infoString = text.slice(openingFence + FENCE.length, lineEnd);
73
- const closingFence = findClosingFence(text, lineEnd + 1, indent);
74
- if (closingFence === null) {
75
- index = lineEnd + 1;
76
- continue;
137
+ const closing = findFenceCloser(lines, index + 1, fence);
138
+ // An unclosed fence runs to the end of the file, so everything after
139
+ // it is the block's literal content and there is nothing left to
140
+ // scan. Resuming on the next line would read the block's interior.
141
+ if (closing === null) {
142
+ return;
77
143
  }
78
- const content = text.slice(lineEnd + 1, closingFence);
79
- const hasContent = content.trim().length > 0;
80
- const hasLanguage = infoString.trim().length > 0;
81
- if (!hasLanguage && hasContent) {
82
- const locStart = sourceCode.getLocFromIndex(openingFence);
83
- const hasCarriageReturn = lineEnd > 0 && text[lineEnd - 1] === '\r';
84
- context.report({
85
- loc: {
86
- start: locStart,
87
- end: sourceCode.getLocFromIndex(lineEnd),
88
- },
89
- messageId: 'missingLanguageSpecifier',
90
- data: { line: locStart.line },
91
- fix: (fixer) => fixer.replaceTextRange([openingFence + FENCE.length, lineEnd], hasCarriageReturn ? 'typescript\r' : 'typescript'),
92
- });
144
+ // A tilde fence and a run of four or more backticks are blocks this
145
+ // rule declines to label, and declining to label a block means
146
+ // declining to read it: its interior is literal text, triple
147
+ // backticks included. So is the interior of a triple-backtick block
148
+ // this rule cannot delimit exactly. Every one of them is skipped from
149
+ // its opening line to past its closing line.
150
+ const labelable = fence.marker === BACKTICK &&
151
+ fence.runLength === LABELABLE_FENCE_LENGTH &&
152
+ isExactlyDelimited(fence, closing.fence);
153
+ if (labelable) {
154
+ const content = text.slice(openingLine.end + 1, lines[closing.line].start);
155
+ const hasContent = content.trim().length > 0;
156
+ const hasLanguage = fence.infoString.trim().length > 0;
157
+ if (!hasLanguage && hasContent) {
158
+ const lineEnd = openingLine.end;
159
+ const locStart = sourceCode.getLocFromIndex(fence.runStart);
160
+ const hasCarriageReturn = lineEnd > 0 && text[lineEnd - 1] === '\r';
161
+ context.report({
162
+ loc: {
163
+ start: locStart,
164
+ end: sourceCode.getLocFromIndex(lineEnd),
165
+ },
166
+ messageId: 'missingLanguageSpecifier',
167
+ data: { line: locStart.line },
168
+ fix: (fixer) => fixer.replaceTextRange([fence.runStart + LABELABLE_FENCE_LENGTH, lineEnd], hasCarriageReturn ? 'typescript\r' : 'typescript'),
169
+ });
170
+ }
93
171
  }
94
- index = closingFence + FENCE.length;
172
+ index = closing.line + 1;
95
173
  }
96
174
  },
97
175
  };
@@ -1278,19 +1278,63 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
1278
1278
  return variableByReference.get(identifier) ?? null;
1279
1279
  }
1280
1280
  /**
1281
- * Whether deleting `element` from the dependency array would leave its
1282
- * binding with no reader left in the file.
1281
+ * Every identifier that a hook dependency array holds as an entry.
1283
1282
  *
1284
- * why: a value declared and then read ONLY inside a dependency array is by
1283
+ * why: an entry is a recompute trigger rather than a consumer of the value,
1284
+ * so it cannot keep a declaration alive once the entries are pruned. The
1285
+ * set is built from the rule's own notion of a dependency array — the last
1286
+ * argument of a hook call — so it can never disagree with what the fixer
1287
+ * edits. Keying on the identifier NODE, rather than on ancestry walked from
1288
+ * a reference, keeps the answer available for entries the traversal has not
1289
+ * reached yet, which is precisely the later-hook entry that #2210 turns on.
1290
+ */
1291
+ let dependencyEntryIdentifiers = null;
1292
+ function collectDependencyEntryIdentifiers() {
1293
+ if (dependencyEntryIdentifiers) {
1294
+ return dependencyEntryIdentifiers;
1295
+ }
1296
+ const entries = new Set();
1297
+ const visit = (node) => {
1298
+ if (node.type === utils_1.AST_NODE_TYPES.CallExpression && isHookCall(node)) {
1299
+ const deps = node.arguments[node.arguments.length - 1];
1300
+ if (deps && deps.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
1301
+ for (const entry of deps.elements) {
1302
+ if (entry)
1303
+ entries.add(unwrapExpression(entry));
1304
+ }
1305
+ }
1306
+ }
1307
+ forEachChildNode(node, visit);
1308
+ };
1309
+ visit(sourceCode.ast);
1310
+ dependencyEntryIdentifiers = entries;
1311
+ return entries;
1312
+ }
1313
+ /**
1314
+ * Whether removing `element`'s binding from every dependency array that
1315
+ * lists it would leave the binding with no reader in the file.
1316
+ *
1317
+ * why: a value declared and then read ONLY inside dependency arrays is by
1285
1318
  * construction load-bearing — the declaration would be pointless otherwise
1286
- * — so removing the entry both discards a deliberate recompute trigger and
1287
- * strands the declaration. The consumer runs `no-unused-vars` as an error
1288
- * and builds with `noUnusedLocals`, so the rewrite turns a green file red
1289
- * on their machine while staying green here. Every sibling instance of this
1290
- * class was fixed by deleting the stranded declaration too, but that remedy
1291
- * is unavailable here: the declaration is a hook CALL, and dropping it
1292
- * changes the component's hook order. Declining the edit is the only safe
1293
- * remedy, so the report stands and the autofix steps aside.
1319
+ * — so removing any one entry discards a deliberate recompute trigger, and
1320
+ * removing all of them strands the declaration. The consumer runs
1321
+ * `no-unused-vars` as an error and builds with `noUnusedLocals`, so the
1322
+ * rewrite turns a green file red on their machine while staying green here.
1323
+ * Every sibling instance of this class was fixed by deleting the stranded
1324
+ * declaration too, but that remedy is unavailable here: the declaration is
1325
+ * a hook CALL, and dropping it changes the component's hook order.
1326
+ * Declining the edit is the only safe remedy, so the report stands and the
1327
+ * autofix steps aside.
1328
+ *
1329
+ * The verdict deliberately ignores which entry is under repair. A test
1330
+ * scoped to one report's own range calls a sibling array's entry a
1331
+ * survivor, and when two hooks list the same unread dependency both reports
1332
+ * reach that conclusion, both fixes apply in one pass, and the binding is
1333
+ * stranded after all (#2210). Discounting every entry instead gates on a
1334
+ * property this fixer owns: no other fixer can manufacture a
1335
+ * non-dependency read, so no sibling edit can move the answer. The
1336
+ * inference is safe in the conservative direction — if a sibling report is
1337
+ * suppressed the cost is a withheld fix, never a dangling reference.
1294
1338
  *
1295
1339
  * Parameters are deliberately exempt. An unread parameter is not an unused
1296
1340
  * BINDING to either instrument — `no-unused-vars` runs with `args: 'none'`
@@ -1308,16 +1352,12 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
1308
1352
  if (variable.defs.some((def) => def.type === 'Parameter')) {
1309
1353
  return false;
1310
1354
  }
1311
- const [start, end] = element.range;
1355
+ const entries = collectDependencyEntryIdentifiers();
1312
1356
  // why: a declarator's own initializer counts as a WRITE reference, so a
1313
1357
  // survivor test that accepts any reference never fires for `const x = …`
1314
1358
  // — the shape this check exists for (#1868 is the same trap).
1315
- return !variable.references.some((reference) => {
1316
- if (!reference.isRead())
1317
- return false;
1318
- const [from, to] = reference.identifier.range;
1319
- return from < start || to > end;
1320
- });
1359
+ return !variable.references.some((reference) => reference.isRead() &&
1360
+ !entries.has(reference.identifier));
1321
1361
  }
1322
1362
  // why: scanning every comment once per file rather than once per hook call
1323
1363
  // keeps the check off the hot path of files with many hooks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.190",
3
+ "version": "1.20.192",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.20.192",
4
+ "date": "2026-08-30T01:20:08.891Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-typescript-markdown-code-blocks",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2213
11
+ ],
12
+ "summary": "never write inside a declined block (closes #2213)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.191",
18
+ "date": "2026-08-29T19:38:08.123Z",
19
+ "rules": [
20
+ {
21
+ "name": "no-entire-object-hook-deps",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 2210
25
+ ],
26
+ "summary": "discount every dependency entry from the strand check (closes #2210)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.20.190",
4
32
  "date": "2026-08-29T13:41:20.482Z",