@blumintinc/eslint-plugin-blumint 1.20.153 → 1.20.154

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.153',
226
+ version: '1.20.154',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -1127,9 +1127,54 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1127
1127
  return variable.defs.every((definition) => definition.name.range[0] >= root.range[0] &&
1128
1128
  definition.name.range[1] <= root.range[1]);
1129
1129
  }
1130
+ /**
1131
+ * Records the instance slot a method call MUTATES THROUGH ITS RECEIVER.
1132
+ * `this.accumulated.set(doc, 1)` publishes to `this.accumulated` exactly as
1133
+ * `this.accumulated = next` does, but it is a CallExpression rather than an
1134
+ * assignment, so the assignment-shaped visit below never sees it. A sweep
1135
+ * that fills an accumulator through the accumulator's own API then reads as
1136
+ * writing nothing, a later await that reads that slot is classified
1137
+ * independent, and the rewrite runs the read against the still-empty
1138
+ * accumulator. (#2017)
1139
+ *
1140
+ * ANY method invoked on the slot counts, rather than a list of known
1141
+ * mutators. The receiver is already the unit barrier 7 treats as ordered,
1142
+ * and a domain `append`/`record`/`write` mutates its receiver exactly as
1143
+ * `set` does, so naming a subset would leave the same silent reorder
1144
+ * reachable under a different spelling. Over-recording a pure
1145
+ * `this.cache.size()` costs only a missed parallelization, which is the
1146
+ * trade this rule takes everywhere.
1147
+ *
1148
+ * Only calls in DEFERRED position qualify -- those the traversal reaches by
1149
+ * crossing into a callback or a resolved callee body. A call spelled in the
1150
+ * operand's own text already carries a receiver key, so barriers 7 and 12
1151
+ * order it with carve-outs calibrated against exactly this: they return no
1152
+ * key for a call-produced receiver (`this.realtimeDb.ref(pathA).remove()`)
1153
+ * or a varying subscript (`this.handlers[0].read()`), which is what keeps
1154
+ * two argument-disambiguated operations on one handle parallelizable.
1155
+ * Recording those same calls here would mint a write on the shared prefix
1156
+ * and silently override that calibration. Behind a callback no receiver key
1157
+ * exists at the operand level at all, so nothing is overridden -- that is
1158
+ * the blind spot, and its whole extent.
1159
+ *
1160
+ * The BARE instance (`this.storeAll()`) is excluded for the same reason:
1161
+ * recording it would mint a wildcard write overlapping every slot, turning
1162
+ * the precise treatment those barriers give it into a blanket one.
1163
+ */
1164
+ function collectMutatedReceiver(call, targets) {
1165
+ const callee = unwrapExpression(call.callee);
1166
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
1167
+ return;
1168
+ }
1169
+ const receiverPath = getInstancePathKey(callee.object);
1170
+ if (receiverPath !== null && receiverPath !== INSTANCE_RECEIVER_KEY) {
1171
+ targets.instancePaths.push(receiverPath);
1172
+ }
1173
+ }
1130
1174
  /**
1131
1175
  * Collects the state an awaited expression WRITES: the identifier names it
1132
- * assigns, and the instance paths (`this.mutator`) it assigns. (#1924)
1176
+ * assigns, and the instance paths (`this.mutator`) it assigns or mutates
1177
+ * through a method call. (#1924, #2017)
1133
1178
  *
1134
1179
  * The traversal deliberately crosses function boundaries, which is the
1135
1180
  * opposite of what containsSuspendingAwait needs: the write that matters
@@ -1144,7 +1189,7 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1144
1189
  */
1145
1190
  function getAssignedState(node) {
1146
1191
  const targets = { identifiers: [], instancePaths: [] };
1147
- const visit = (current) => {
1192
+ const visit = (current, deferred) => {
1148
1193
  if (current.type === utils_1.AST_NODE_TYPES.AssignmentExpression) {
1149
1194
  collectAssignmentTarget(current.left, targets);
1150
1195
  }
@@ -1158,6 +1203,10 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1158
1203
  // iteration; only the declaration form introduces a fresh local.
1159
1204
  collectAssignmentTarget(current.left, targets);
1160
1205
  }
1206
+ else if (deferred && current.type === utils_1.AST_NODE_TYPES.CallExpression) {
1207
+ collectMutatedReceiver(current, targets);
1208
+ }
1209
+ const childrenDeferred = deferred || FUNCTION_BOUNDARY_TYPES.has(current.type);
1161
1210
  for (const key in current) {
1162
1211
  if (key === 'parent' || key === 'range' || key === 'loc')
1163
1212
  continue;
@@ -1167,16 +1216,19 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1167
1216
  if (Array.isArray(child)) {
1168
1217
  for (const item of child) {
1169
1218
  if (item && typeof item === 'object' && 'type' in item) {
1170
- visit(item);
1219
+ visit(item, childrenDeferred);
1171
1220
  }
1172
1221
  }
1173
1222
  }
1174
1223
  else if ('type' in child) {
1175
- visit(child);
1224
+ visit(child, childrenDeferred);
1176
1225
  }
1177
1226
  }
1178
1227
  };
1179
- visit(node);
1228
+ // A resolved callee body is itself deferred relative to the run, and
1229
+ // entering it crosses its own function boundary, so the flag lifts here
1230
+ // exactly as it does for a callback. (#1989, #2017)
1231
+ visit(node, false);
1180
1232
  const names = new Set();
1181
1233
  for (const target of targets.identifiers) {
1182
1234
  if (!isDeclaredWithin(target, node)) {
@@ -1445,11 +1497,26 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1445
1497
  ]),
1446
1498
  };
1447
1499
  });
1500
+ //
1501
+ // The READ side resolves the callee body for the same reason the write
1502
+ // side does, and the omission was the other half of #2017: `await
1503
+ // this.storeAll()` spells only the slot `this.storeAll`, so a preceding
1504
+ // write to `this.accumulated` -- the slot `storeAll` actually reads --
1505
+ // compares as disjoint and the pair parallelizes. Reading the resolved
1506
+ // body restores the edge. An unresolvable callee (inherited, computed,
1507
+ // imported) yields null and leaves the operand keyed on its own text, as
1508
+ // before.
1448
1509
  const readInstancePaths = awaitNodes.map((node) => {
1449
1510
  const awaitExpr = getAwaitExpression(node);
1450
- return awaitExpr
1451
- ? getInstancePathKeys(awaitExpr.argument)
1452
- : new Set();
1511
+ if (!awaitExpr) {
1512
+ return new Set();
1513
+ }
1514
+ const read = getInstancePathKeys(awaitExpr.argument);
1515
+ const calleeFunction = resolveCalleeFunction(awaitExpr);
1516
+ if (!calleeFunction) {
1517
+ return read;
1518
+ }
1519
+ return new Set([...read, ...getInstancePathKeys(calleeFunction)]);
1453
1520
  });
1454
1521
  for (let i = 1; i < awaitNodes.length; i++) {
1455
1522
  const currentIds = allIdentifiers[i];
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared machinery for reading the documented examples out of `docs/rules/*.md`
3
+ * and linting them.
4
+ *
5
+ * Extracted so that more than one guard can ask a question of the SAME parsed
6
+ * corpus. `docs-examples-conformance` asks whether a block satisfies its own
7
+ * rule; `docs-correct-block-regression` asks whether the blocks #1982 fixed
8
+ * still satisfy the OTHER rule that used to report on them. Hand-rolling the
9
+ * fence walker or the candidate-filename list a second time is how two guards
10
+ * come to disagree about which blocks exist — the failure `fixtureCorpus.ts`
11
+ * exists to prevent on the RuleTester side, and the reason four guards there
12
+ * inherited the same two silent losses (#1984).
13
+ *
14
+ * The filename list in particular is load-bearing and must not be duplicated:
15
+ * many rules key off the path, so judging a block under a path the rule was
16
+ * never meant to see manufactures a failure.
17
+ */
18
+ export declare const PREFIX = "@blumintinc/blumint/";
19
+ export declare const DOCS_DIR: string;
20
+ export declare const pageExists: (rule: string) => boolean;
21
+ export declare const readPage: (rule: string) => string | null;
22
+ /** Fence languages that hold lintable TypeScript. */
23
+ export declare const LINTABLE_LANGS: Set<string>;
24
+ /**
25
+ * Candidate filenames, tried in order. Many rules key off the path (cloud
26
+ * function entry points, test-file exemptions, component directories), so a
27
+ * single hard-coded filename would make correct examples report for reasons the
28
+ * doc never claimed.
29
+ */
30
+ export declare const TS_CANDIDATES: string[];
31
+ export declare const TSX_CANDIDATES: string[];
32
+ /**
33
+ * Rules that match on path segments need a rooted path — `functions/src/types/x.ts`
34
+ * relative does not satisfy the same check that `/repo/functions/src/types/x.ts`
35
+ * does, which would fail a doc example for a reason the doc never claimed.
36
+ */
37
+ export declare const ROOT = "/repo/";
38
+ export declare const anchor: (p: string) => string;
39
+ export type Block = {
40
+ /**
41
+ * `null` for a fence under no example heading. Such blocks are kept rather
42
+ * than dropped: a page whose fences all come back unlabelled is a detection
43
+ * failure, and dropping them made it indistinguishable from a page that
44
+ * documents no examples at all (#1499).
45
+ */
46
+ polarity: 'correct' | 'incorrect' | null;
47
+ lang: string;
48
+ code: string;
49
+ line: number;
50
+ };
51
+ /**
52
+ * Classify an example heading.
53
+ *
54
+ * Only H2+ headings count: the H1 title ends with the rule id, and rule names
55
+ * routinely contain `prefer`, `valid`, or `no`, which would otherwise classify
56
+ * every block in the intro prose. The rule-id parenthetical is stripped for the
57
+ * same reason.
58
+ *
59
+ * Order matters — "incorrect" contains "correct" and "invalid" contains "valid",
60
+ * so the negative spellings must be tested first.
61
+ */
62
+ export declare function headingPolarity(line: string): Block['polarity'] | null;
63
+ /**
64
+ * Pull every fenced code block, tagged with the polarity of the example heading
65
+ * it sits under (`null` when it sits under none).
66
+ *
67
+ * Polarity is inherited by DEEPER headings, because docs routinely split an
68
+ * example section into named cases (`#### Option 1: …` under `### Examples of
69
+ * correct code`). Treating such a sub-heading as the end of the section dropped
70
+ * every block beneath it, which is how three whole pages asserted nothing.
71
+ */
72
+ export declare function extractBlocks(md: string): Block[];
73
+ /**
74
+ * Docs declare the context a snippet assumes inside the snippet itself:
75
+ * `// File: functions/src/...` (or a bare path comment) for path-sensitive
76
+ * rules, and `// eslint-options: {...}` for an example that only holds under a
77
+ * non-default option. Honouring both is what lets every correct block be
78
+ * enforced without exempting the awkward ones.
79
+ */
80
+ export declare function filenameHint(code: string): string | null;
81
+ export declare function optionsHint(code: string): unknown | null;
82
+ export type LintResult = {
83
+ reports: string[];
84
+ /** 1-based lines of the same reports, for segment attribution (#1622). */
85
+ reportLines: number[];
86
+ skipped: boolean;
87
+ reason?: string;
88
+ };
89
+ export declare function lintBlock(ruleName: string, filename: string, code: string, options: unknown | null): LintResult;
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.lintBlock = exports.optionsHint = exports.filenameHint = exports.extractBlocks = exports.headingPolarity = exports.anchor = exports.ROOT = exports.TSX_CANDIDATES = exports.TS_CANDIDATES = exports.LINTABLE_LANGS = exports.readPage = exports.pageExists = exports.DOCS_DIR = exports.PREFIX = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const eslint_1 = require("eslint");
10
+ /* eslint-disable @typescript-eslint/no-var-requires */
11
+ const plugin = require('../index');
12
+ const tsParser = require('@typescript-eslint/parser');
13
+ /* eslint-enable @typescript-eslint/no-var-requires */
14
+ /**
15
+ * Shared machinery for reading the documented examples out of `docs/rules/*.md`
16
+ * and linting them.
17
+ *
18
+ * Extracted so that more than one guard can ask a question of the SAME parsed
19
+ * corpus. `docs-examples-conformance` asks whether a block satisfies its own
20
+ * rule; `docs-correct-block-regression` asks whether the blocks #1982 fixed
21
+ * still satisfy the OTHER rule that used to report on them. Hand-rolling the
22
+ * fence walker or the candidate-filename list a second time is how two guards
23
+ * come to disagree about which blocks exist — the failure `fixtureCorpus.ts`
24
+ * exists to prevent on the RuleTester side, and the reason four guards there
25
+ * inherited the same two silent losses (#1984).
26
+ *
27
+ * The filename list in particular is load-bearing and must not be duplicated:
28
+ * many rules key off the path, so judging a block under a path the rule was
29
+ * never meant to see manufactures a failure.
30
+ */
31
+ exports.PREFIX = '@blumintinc/blumint/';
32
+ exports.DOCS_DIR = path_1.default.join(__dirname, '../../docs/rules');
33
+ const pageExists = (rule) => fs_1.default.existsSync(path_1.default.join(exports.DOCS_DIR, `${rule}.md`));
34
+ exports.pageExists = pageExists;
35
+ const readPage = (rule) => (0, exports.pageExists)(rule)
36
+ ? fs_1.default.readFileSync(path_1.default.join(exports.DOCS_DIR, `${rule}.md`), 'utf8')
37
+ : null;
38
+ exports.readPage = readPage;
39
+ /** Fence languages that hold lintable TypeScript. */
40
+ exports.LINTABLE_LANGS = new Set([
41
+ 'ts',
42
+ 'tsx',
43
+ 'js',
44
+ 'jsx',
45
+ 'typescript',
46
+ 'javascript',
47
+ '',
48
+ ]);
49
+ /**
50
+ * Candidate filenames, tried in order. Many rules key off the path (cloud
51
+ * function entry points, test-file exemptions, component directories), so a
52
+ * single hard-coded filename would make correct examples report for reasons the
53
+ * doc never claimed.
54
+ */
55
+ exports.TS_CANDIDATES = [
56
+ 'src/util/helper.ts',
57
+ 'functions/src/callable/handler.f.ts',
58
+ 'functions/src/util/helper.ts',
59
+ 'src/util/helper.test.ts',
60
+ 'src/components/Widget.tsx',
61
+ ];
62
+ exports.TSX_CANDIDATES = [
63
+ 'src/components/Widget.tsx',
64
+ 'src/pages/index.tsx',
65
+ ];
66
+ /**
67
+ * Rules that match on path segments need a rooted path — `functions/src/types/x.ts`
68
+ * relative does not satisfy the same check that `/repo/functions/src/types/x.ts`
69
+ * does, which would fail a doc example for a reason the doc never claimed.
70
+ */
71
+ exports.ROOT = '/repo/';
72
+ const anchor = (p) => (p.startsWith('/') ? p : exports.ROOT + p);
73
+ exports.anchor = anchor;
74
+ /**
75
+ * Classify an example heading.
76
+ *
77
+ * Only H2+ headings count: the H1 title ends with the rule id, and rule names
78
+ * routinely contain `prefer`, `valid`, or `no`, which would otherwise classify
79
+ * every block in the intro prose. The rule-id parenthetical is stripped for the
80
+ * same reason.
81
+ *
82
+ * Order matters — "incorrect" contains "correct" and "invalid" contains "valid",
83
+ * so the negative spellings must be tested first.
84
+ */
85
+ function headingPolarity(line) {
86
+ if (!/^#{2,6}\s/.test(line))
87
+ return null;
88
+ const text = line
89
+ .replace(/^#{2,6}\s*/, '')
90
+ .replace(/\(`?@blumintinc\/blumint\/[^)]*`?\)/g, '')
91
+ .toLowerCase();
92
+ if (/❌|👎|\bincorrect\b|\binvalid\b|\bbad\b|\bwrong\b/.test(text))
93
+ return 'incorrect';
94
+ if (/✅|👍|\bcorrect\b|\bvalid\b|\bgood\b/.test(text))
95
+ return 'correct';
96
+ return null;
97
+ }
98
+ exports.headingPolarity = headingPolarity;
99
+ /**
100
+ * Pull every fenced code block, tagged with the polarity of the example heading
101
+ * it sits under (`null` when it sits under none).
102
+ *
103
+ * Polarity is inherited by DEEPER headings, because docs routinely split an
104
+ * example section into named cases (`#### Option 1: …` under `### Examples of
105
+ * correct code`). Treating such a sub-heading as the end of the section dropped
106
+ * every block beneath it, which is how three whole pages asserted nothing.
107
+ */
108
+ function extractBlocks(md) {
109
+ const lines = md.split('\n');
110
+ const blocks = [];
111
+ let polarity = null;
112
+ let polarityDepth = 0;
113
+ let fence = null;
114
+ let buf = [];
115
+ let lang = '';
116
+ let startLine = 0;
117
+ for (let i = 0; i < lines.length; i += 1) {
118
+ const line = lines[i];
119
+ const fenceMatch = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
120
+ if (fence) {
121
+ if (fenceMatch &&
122
+ fenceMatch[1][0] === fence[0] &&
123
+ fenceMatch[1].length >= fence.length) {
124
+ blocks.push({
125
+ polarity,
126
+ lang: lang.trim().toLowerCase(),
127
+ code: buf.join('\n'),
128
+ line: startLine,
129
+ });
130
+ fence = null;
131
+ buf = [];
132
+ }
133
+ else {
134
+ buf.push(line);
135
+ }
136
+ continue;
137
+ }
138
+ const heading = /^(#{1,6})\s/.exec(line);
139
+ if (heading) {
140
+ const depth = heading[1].length;
141
+ const own = headingPolarity(line);
142
+ if (own) {
143
+ polarity = own;
144
+ polarityDepth = depth;
145
+ }
146
+ else if (!(polarity && depth > polarityDepth)) {
147
+ // A sibling or shallower heading ends the example section; a deeper one
148
+ // is a named case inside it and keeps the section's polarity.
149
+ polarity = null;
150
+ polarityDepth = 0;
151
+ }
152
+ continue;
153
+ }
154
+ if (fenceMatch) {
155
+ fence = fenceMatch[1];
156
+ lang = fenceMatch[2] || '';
157
+ startLine = i + 1;
158
+ buf = [];
159
+ }
160
+ }
161
+ return blocks;
162
+ }
163
+ exports.extractBlocks = extractBlocks;
164
+ /**
165
+ * Docs declare the context a snippet assumes inside the snippet itself:
166
+ * `// File: functions/src/...` (or a bare path comment) for path-sensitive
167
+ * rules, and `// eslint-options: {...}` for an example that only holds under a
168
+ * non-default option. Honouring both is what lets every correct block be
169
+ * enforced without exempting the awkward ones.
170
+ */
171
+ function filenameHint(code) {
172
+ const explicit = /^\s*(?:\/\/|\/\*)\s*File:\s*([^\s*]+)/im.exec(code);
173
+ if (explicit)
174
+ return (0, exports.anchor)(explicit[1].replace(/^\.\//, ''));
175
+ const firstLine = code.split('\n').find((l) => l.trim().length > 0) || '';
176
+ const bare = /^\s*\/\/\s*((?:[\w.-]+\/)+[\w.-]+\.tsx?)\b/.exec(firstLine);
177
+ return bare ? (0, exports.anchor)(bare[1]) : null;
178
+ }
179
+ exports.filenameHint = filenameHint;
180
+ function optionsHint(code) {
181
+ const m = /^\s*\/\/\s*eslint-options:\s*(\{.*\})\s*$/im.exec(code);
182
+ if (!m)
183
+ return null;
184
+ try {
185
+ return JSON.parse(m[1]);
186
+ }
187
+ catch {
188
+ throw new Error(`malformed // eslint-options: ${m[1]}`);
189
+ }
190
+ }
191
+ exports.optionsHint = optionsHint;
192
+ const linter = new eslint_1.Linter();
193
+ for (const [name, rule] of Object.entries(plugin.rules)) {
194
+ linter.defineRule(exports.PREFIX + name, rule);
195
+ }
196
+ linter.defineParser('ts', tsParser);
197
+ function lintBlock(ruleName, filename, code, options) {
198
+ const config = {
199
+ parser: 'ts',
200
+ parserOptions: {
201
+ ecmaVersion: 2022,
202
+ sourceType: 'module',
203
+ ecmaFeatures: { jsx: filename.endsWith('.tsx') },
204
+ },
205
+ rules: {
206
+ [exports.PREFIX + ruleName]: options
207
+ ? ['error', options]
208
+ : 'error',
209
+ },
210
+ };
211
+ let messages;
212
+ try {
213
+ messages = linter.verify(code, config, { filename });
214
+ }
215
+ catch (error) {
216
+ // A rule needing type information throws without `parserOptions.project`,
217
+ // which the RuleTester cannot supply; such rules are out of scope here.
218
+ return {
219
+ reports: [],
220
+ reportLines: [],
221
+ skipped: true,
222
+ reason: `the rule threw: ${error.message}`,
223
+ };
224
+ }
225
+ // A block that does not parse never ran the rule. That is not a pass — see
226
+ // UNCHECKABLE_BLOCKS.
227
+ const fatal = messages.find((m) => m.fatal);
228
+ if (fatal) {
229
+ return {
230
+ reports: [],
231
+ reportLines: [],
232
+ skipped: true,
233
+ reason: `parse failure at block line ${fatal.line}: ${fatal.message}`,
234
+ };
235
+ }
236
+ const mine = messages.filter((m) => m.ruleId === exports.PREFIX + ruleName);
237
+ return {
238
+ reports: mine.map((m) => `line ${m.line}: ${m.message}`),
239
+ reportLines: mine.map((m) => m.line),
240
+ skipped: false,
241
+ };
242
+ }
243
+ exports.lintBlock = lintBlock;
244
+ //# sourceMappingURL=docsFixtures.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.153",
3
+ "version": "1.20.154",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "1.20.154",
4
+ "date": "2026-08-15T03:47:51.570Z",
5
+ "rules": [
6
+ {
7
+ "name": "parallelize-async-operations",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2017
11
+ ],
12
+ "summary": "order callback-deferred instance mutations against later reads (closes #2017)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.20.153",
4
18
  "date": "2026-08-14T20:21:23.691Z",